From f609dd2767b431c49b37a574cb64883f4de599bb Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 16:16:38 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat(m02-cf):=20=EA=B8=B0=EA=B3=84=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=20=EA=B0=92=20=E2=80=94=20=EC=9E=85=EB=A0=A5?= =?UTF-8?q?=20=ED=8C=90=20ref=20=E3=80=8CEQ=ED=82=A4#=EC=B9=B8=E3=80=8D=20?= =?UTF-8?q?=C2=B7=20=EC=B9=B8=20=E3=80=8C=EC=8B=9C=EA=B0=84=EB=8B=B9=20?= =?UTF-8?q?=EA=B2=BD=EB=B9=84=E3=80=8D(=EA=B8=B0=EA=B3=84=EA=B2=BD?= =?UTF-8?q?=EB=B9=84=20=EB=A1=9C=EC=A7=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /cf/values · 저장 다시 넣기가 기계 ref 「EQ키#칸」 과 {ref: 키, field: 칸} 둘 다 받음 - 칸 「시간당 경비」 = 기종(분류번호)을 받는 기계경비 로직 가운데 짝 차례 첫째(옛 EasyCalc pickMachine 규칙을 옮김) · 빈 입력은 첫 고르기(지역 = 전국평균) · inputs 로 바꿈 - 시험 — 키#칸 = {ref, field} = 기계 표 값 · 시간당 경비 = 그 로직 /calc 계 · 저장 때 같은 값 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL --- M02_MasterTemplete/CostFlow/M02_CF_Router.py | 70 ++++++++++++++++++-- resources/tester/test_m02_cf_router.py | 46 +++++++++++++ 2 files changed, 112 insertions(+), 4 deletions(-) diff --git a/M02_MasterTemplete/CostFlow/M02_CF_Router.py b/M02_MasterTemplete/CostFlow/M02_CF_Router.py index cc7aa882f..a611eca45 100644 --- a/M02_MasterTemplete/CostFlow/M02_CF_Router.py +++ b/M02_MasterTemplete/CostFlow/M02_CF_Router.py @@ -342,10 +342,70 @@ def _user(uid: str, local: list[dict]) -> dict: return {"ok": True, "value": one["value"], "name": one.get("name"), "unit": one.get("unit")} +MACHINE_SEP = "#" # 입력 판(sub3) 기계 요소 ref 「EQ키#칸」 +HOURLY = "시간당 경비" # 기계 칸 이름 — 기계 시간당 경비 로직으로 셈(기종 → 로직 짝) +_MACHINE_PLAIN = {"기계", "지역", "보정작업", "조종원직종"} # 이 밖의 입력 = 부착 장비 전용 + + +def _machine_family() -> list[dict]: + """기계 시간당 경비 로직 — 「건설기계」 장 파일 안 · 입력 「기계」 고르기가 있는 돈 로직.""" + files = store.loaded()[0] + return [ + row + for name, data in files.items() + if data.get("그룹") == "로직" and "건설기계" in name + for row in data.get("줄") or [] + if str(row.get("결과단위") or "").startswith("원") + and any(i.get("이름") == "기계" and i.get("고르기") for i in row.get("입력") or []) + ] + + +def _machine_rank(row: dict) -> tuple: + """짝 차례(옛 EasyCalc `pickMachine` 와 같음) — 부착 전용 · 손료만 · 보정 · 기종 수 · 키.""" + names = [i.get("이름") for i in row.get("입력") or []] + codes = next(i.get("고르기") for i in row["입력"] if i.get("이름") == "기계") + attach = any(n not in _MACHINE_PLAIN for n in names) + loss = "지역" not in names + return (attach, loss, loss and "보정작업" in names, len(codes), str(row.get("키"))) + + +def _machine_hourly(key: str, inputs: dict) -> dict: + """기계 한 시간 값 — 기종을 받는 기계경비 로직 가운데 짝 차례 첫째 · 빈 입력은 첫 고르기.""" + row, why = _brief(key, "기계") + if row is None: + return {"ok": False, "reason": why} + code = str(row.get("원문번호") or "") + fit = [r for r in _machine_family() if code in [str(c) for c in _codes(r)]] + if not fit: + return {"ok": False, "reason": f"「{row.get('이름')}」 은 시간당 경비를 셀 자료가 없음"} + logic = min(fit, key=_machine_rank) + given = {} + for spec in logic.get("입력") or []: + name = spec.get("이름") + if name != "기계": + given[name] = inputs.get(name, (spec.get("고르기") or [None])[0]) + args = "".join(f", {n}='{v}'" for n, v in given.items()) + ref = f"로직({logic['키']}, 기계='{code}'{args})" + line = {"종류": "로직", "요소": ref, "이름": "기계", "단위": "시간", "수량": "1"} + got = _money(store.calc("", {}, _row(line, ()), None), "계") + return {**got, "name": row.get("이름"), "unit": "원/시간"} if got["ok"] else got + + +def _codes(row: dict) -> list: + return next( + (i.get("고르기") or [] for i in row.get("입력") or [] if i.get("이름") == "기계"), [] + ) + + def one_value(item: dict, local: list[dict] | None = None) -> dict: - """물음 한 줄 `CfQuery {id, group, ref? · table? · unit? · field? · inputs?}` → `CfAnswer`.""" + """물음 한 줄 `CfQuery {id, group, ref? · table? · unit? · field? · inputs?}` → `CfAnswer`. + + 기계 ref 「EQ키#칸」(입력 판 요소 ref)도 받음 · 칸 「시간당 경비」 = 기계경비 로직으로 셈. + """ group, ref = item.get("group"), item.get("ref") - table = item.get("table") or {} + table, field = item.get("table") or {}, item.get("field") + if group == "기계" and isinstance(ref, str) and MACHINE_SEP in ref: + ref, _, field = ref.partition(MACHINE_SEP) try: if isinstance(ref, str) and _UKEY.match(ref): got = _user(ref, local or []) @@ -358,9 +418,11 @@ def one_value(item: dict, local: list[dict] | None = None) -> dict: elif group == "기계" and str(ref or "").startswith("로직("): inputs = item.get("inputs") or {} line = {"종류": "로직", "요소": ref, "이름": "기계", "단위": "시간", "수량": "1"} - got = _money(store.calc("", inputs, _row(line, inputs), None), item.get("field")) + got = _money(store.calc("", inputs, _row(line, inputs), None), field) + elif group == "기계" and field == HOURLY: + got = _machine_hourly(str(ref or ""), item.get("inputs") or {}) elif group in ("기계", "유가전력", "환율", "요율"): - got = _plain_value(group, str(ref or ""), item.get("field")) + got = _plain_value(group, str(ref or ""), field) else: got = {"ok": False, "reason": f"모르는 그룹 「{group}」"} except store.StoreError as e: diff --git a/resources/tester/test_m02_cf_router.py b/resources/tester/test_m02_cf_router.py index bbeeddc59..6b8e4bf83 100644 --- a/resources/tester/test_m02_cf_router.py +++ b/resources/tester/test_m02_cf_router.py @@ -305,3 +305,49 @@ def test_시스템_관리자가_아니면_403(folder: Path) -> None: assert client.get("/api/m02/cf/user-elements").status_code == 403 app.dependency_overrides[verify_session] = lambda: {"role": "SYSTEM_ADMIN", "company_id": None} assert [x["key"] for x in client.get("/api/m02/cf/list").json()["logics"]] == ["견본 돌쌓기"] + + +@pytest.mark.skipif(NO_NODE, reason="node 가 없음") +def test_기계_키_칸_꼴과_시간당_경비(client: TestClient) -> None: + """입력 판(sub3) 기계 ref 「EQ키#칸」 · 물음 {ref, field} · 「시간당 경비」 = 기계경비 로직.""" + logic = min( + (r for r in cf._machine_family() if "지역" in [i["이름"] for i in r["입력"]]), + key=cf._machine_rank, + ) + code = str(cf._codes(logic)[0]) + eq = _first("기계.json", lambda r: str(r.get("원문번호")) == code) + family = [r for r in cf._machine_family() if code in [str(c) for c in cf._codes(r)]] + chosen = min(family, key=cf._machine_rank) + given = { + i["이름"]: (code if i["이름"] == "기계" else (i.get("고르기") or [None])[0]) + for i in chosen["입력"] + } + want = _post(client, "/api/m01/calc", {"key": chosen["키"], "inputs": given})["sums"]["계"] + + items = [ + {"id": "p", "group": "기계", "ref": f"{eq['키']}#가격"}, + {"id": "p2", "group": "기계", "ref": eq["키"], "field": "가격"}, + {"id": "h", "group": "기계", "ref": f"{eq['키']}#시간당 경비"}, + {"id": "h2", "group": "기계", "ref": eq["키"], "field": "시간당 경비"}, + {"id": "x", "group": "기계", "ref": f"{eq['키']}#없는칸"}, + ] + got = {v["id"]: v for v in _post(client, "/api/m02/cf/values", {"items": items})["values"]} + assert got["p"]["value"] == got["p2"]["value"] == eq["값"]["가격"] + assert got["h"]["value"] == got["h2"]["value"] == want and got["h"]["unit"] == "원/시간" + assert got["x"]["ok"] is False and "없는칸" not in got["x"].get("value", "") + + # 저장 — 입력 상자 요소 ref 그대로(「EQ키#칸」) + box = _box("m1", "input", "기계", input={"mode": "group", "group": "기계", "variables": [], + "elements": [{"ref": f"{eq['키']}#시간당 경비", "name": "기계 · 시간당 경비", + "spec": "", "unit": "원/시간"}]}) # fmt: skip + f1 = _box("f1", "function", "곱", func=_func( + {"id": "s1", "이름": "곱", "칸": {"B1": {"식": "A1*2"}}}, + [{"port": "단가", "cell": "A1"}], + {"cell": "B1", "name": "경비", "unit": "원"})) # fmt: skip + o1 = _box("o1", "output", "경비", output={"cost": "경비", "unit": "원"}) + body = {"key": None, "name": "기계 견본", "resultUnit": "원/시간", "boxes": [box, f1, o1], + "links": [_link("a", "m1", "기계 · 시간당 경비", "f1", "단가"), + _link("b", "f1", "경비", "o1", "값")], "userElements": []} # fmt: skip + saved = _post(client, "/api/m02/cf/save", {"logic": body}) + assert _d(saved["values"]["cost"]["경비"]) == 2 * _d(want) + _post(client, "/api/m02/cf/delete", {"key": saved["key"]}) From dfd4810bf1bd4a0612d87e22632634479de30794 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 16:21:27 +0900 Subject: [PATCH 2/5] =?UTF-8?q?fix(m02-cf):=20=EA=B2=AC=EB=B3=B8=20?= =?UTF-8?q?=EB=A9=94=EC=8C=93=EA=B8=B0=20=EB=86=92=EC=9D=B4=20=EC=A6=9D?= =?UTF-8?q?=EA=B0=80=EC=9C=A8=ED=91=9C=20=EC=9E=85=EB=A0=A5=20=EA=B7=B8?= =?UTF-8?q?=EB=A3=B9=EC=9D=84=20=EC=86=8C=EC=9A=94=EB=9F=89=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=C2=B7=20=EC=8B=9C=ED=97=98=EC=9D=B4=20=EB=A7=88?= =?UTF-8?q?=EC=8A=A4=ED=84=B0=20=EA=B7=B8=EB=A3=B9=EA=B3=BC=20=EB=8C=80?= =?UTF-8?q?=EC=A1=B0?= 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_01BU9gbNE3aWFUvE61pS2WGt --- M02_MasterTemplete/CostFlow/_sample/masonry.json | 2 +- resources/tester/test_m02_cf_sample.py | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/M02_MasterTemplete/CostFlow/_sample/masonry.json b/M02_MasterTemplete/CostFlow/_sample/masonry.json index a57860530..3182b5a6a 100644 --- a/M02_MasterTemplete/CostFlow/_sample/masonry.json +++ b/M02_MasterTemplete/CostFlow/_sample/masonry.json @@ -73,7 +73,7 @@ "y": 460, "input": { "mode": "group", - "group": "계수", + "group": "소요량", "elements": [], "table": { "key": "QF000422" diff --git a/resources/tester/test_m02_cf_sample.py b/resources/tester/test_m02_cf_sample.py index ec59c4ed4..863354316 100644 --- a/resources/tester/test_m02_cf_sample.py +++ b/resources/tester/test_m02_cf_sample.py @@ -224,6 +224,17 @@ def _run(lg: dict, master) -> dict: return {b["id"]: ports[b["id"]]["값"] for b in lg["boxes"] if b["kind"] == "output"} +def test_견본_표_그룹이_마스터와_같음(master) -> None: + """표 입력 상자의 group = 그 표가 든 마스터 파일 그룹(소요량 · 계수)""" + cm, files = master + group = {str(t["키"]): d["그룹"] for d in files.values() if d.get("그룹") in cm.mf.TABLE_GROUPS for t in d.get("표") or []} # fmt: skip + for lg in SAMPLES.values(): + for b in lg["boxes"]: + table = b.get("input", {}).get("table") + if table: + assert b["input"]["group"] == group[table["key"]], (b["id"], table["key"]) + + def test_견본1_원가_calc_와_같음(master) -> None: from M01_MasterData.LogicBuilder import M01_LB_Calc as lb From 2ea48832730080f225524e134aeb163fe1ddd81f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 16:21:55 +0900 Subject: [PATCH 3/5] =?UTF-8?q?fix(M02-CostFlow):=20FuncPanel=20=EC=B9=B8?= =?UTF-8?q?=20=EC=97=86=EB=8A=94=20=EB=B0=9B=EB=8A=94=20=EC=95=8C=EC=95=BD?= =?UTF-8?q?=EC=9D=84=20=EC=95=84=EC=A7=81=20=EC=95=88=20=EB=86=93=EC=9D=80?= =?UTF-8?q?=20=EC=95=8C=EC=95=BD=EC=9C=BC=EB=A1=9C=20=EB=B3=B4=EC=9E=84=20?= =?UTF-8?q?-=20=EC=84=A0=20=EC=97=86=EC=9D=B4=EB=8F=84=20=EB=B3=B4?= =?UTF-8?q?=EC=9D=B4=EA=B3=A0=20=EC=B9=B8=EC=97=90=20=EB=86=93=EC=9C=BC?= =?UTF-8?q?=EB=A9=B4=20=EC=9E=A0=EA=B8=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c --- M02_MasterTemplete/CostFlow/M02_CF_FuncPanel.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/M02_MasterTemplete/CostFlow/M02_CF_FuncPanel.ts b/M02_MasterTemplete/CostFlow/M02_CF_FuncPanel.ts index f43861d16..84dad666f 100644 --- a/M02_MasterTemplete/CostFlow/M02_CF_FuncPanel.ts +++ b/M02_MasterTemplete/CostFlow/M02_CF_FuncPanel.ts @@ -24,7 +24,7 @@ const TEXT = { GotHint: ["누르거나 칸에 끌어 놓음", "Click, or drag onto a cell"], NoGot: ["선으로 들어온 알약이 없음", "No pill has come in yet"], Placed: ["놓임", "placed"], - Free: ["놓을 칸 없음", "not placed"], + Free: ["아직 안 놓은 알약", "not placed yet"], Busy: ["이미 쓰는 칸", "That cell is already in use"], Locked: [ "받은 알약이 놓인 칸은 선의 값으로 채워짐 — 직접 못 고침", @@ -260,6 +260,8 @@ function incoming(st: State): { port: string; unit: string }[] { "-"); out.set(l.to.name, unit || "-"); } + // 선 없이 칸 없는 받는 알약만 있어도 보임(새 함수 상자가 먼저 만든 자리) + for (const i of funcOf(st)?.inputs ?? []) if (!out.has(i.port)) out.set(i.port, "-"); return [...out].map(([port, unit]) => ({ port, unit })); } @@ -273,7 +275,7 @@ function problems(st: State): string[] { } } for (const p of incoming(st)) { - if (!f.inputs.some((i) => i.port === p.port)) out.push(`${p.port}${tf("Unplaced")}`); + if (!f.inputs.some((i) => i.port === p.port && i.cell)) out.push(`${p.port}${tf("Unplaced")}`); } if (!f.output.cell) out.push(tf("OutNone")); return out; @@ -285,12 +287,13 @@ function applyMarks(st: State): void { const f = funcOf(st); if (!f) return; const marks = new Map(); - for (const i of f.inputs) marks.set(i.cell, { kind: "in", label: `${tf("Got")} ${i.port}` }); + for (const i of f.inputs.filter((x) => x.cell)) + marks.set(i.cell, { kind: "in", label: `${tf("Got")} ${i.port}` }); for (const v of f.variables) if (v.cell) marks.set(v.cell, { kind: "var", label: `${tf("Vars")} ${v.name}` }); if (f.output.cell) marks.set(f.output.cell, { kind: "out", label: `${tf("OutNow")} ${f.output.name}` }); - const locked = new Set(f.inputs.map((i) => i.cell)); + const locked = new Set(f.inputs.filter((i) => i.cell).map((i) => i.cell)); const list = problems(st); const bad = new Set(list.map((p) => p.split(" ")[0])); for (const c of st.parts.sheet.querySelectorAll(".aislo-cell")) { @@ -581,7 +584,7 @@ function onBook(st: State, book: Workbook): void { if (!f) return; const sheet = plain(structuredClone(book.시트[0])); const hit = f.inputs.find( - (i) => JSON.stringify(f.sheet.칸[i.cell]) !== JSON.stringify(sheet.칸[i.cell]), + (i) => i.cell && JSON.stringify(f.sheet.칸[i.cell]) !== JSON.stringify(sheet.칸[i.cell]), ); if (hit) { showToast(tf("Locked"), "error"); From 71c20453e4455a53d2ee6da8c05a64ec41d695b2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 16:22:01 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix(M02):=20=EB=8B=A8=EA=B0=80=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8=EB=9E=A8=20?= =?UTF-8?q?=E2=80=94=20=ED=95=A8=EC=88=98=20=EC=83=81=EC=9E=90=20=EB=AA=B8?= =?UTF-8?q?=ED=86=B5=20=C2=B7=20=E3=80=8C+=E3=80=8D=20=EB=B0=9B=EB=8A=94?= =?UTF-8?q?=20=EC=9E=90=EB=A6=AC=EC=97=90=20=EB=86=93=EC=9C=BC=EB=A9=B4=20?= =?UTF-8?q?=EB=B3=B4=EB=82=B8=20=EC=95=8C=EC=95=BD=20=EC=9D=B4=EB=A6=84?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20on.link(PLAN=2035-7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 받는 알약 0 개인 새 함수 상자도 이을 수 있음 · 받는 알약은 Model 이 새로 만듦 - 함수 상자 왼쪽 흐린 「+」 받는 자리 · 잇기 중이면 밝게 - ORCA: 빈 함수 상자 몸통 ← 면적 · 「+」 ← 높이 → on.link 두 번(이름 그대로) · CostFlow 전부 검사 초록 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z --- .../CostFlow/M02_CF_Diagram.css | 18 +++++++++ M02_MasterTemplete/CostFlow/M02_CF_Diagram.ts | 37 ++++++++++++++++++- 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/M02_MasterTemplete/CostFlow/M02_CF_Diagram.css b/M02_MasterTemplete/CostFlow/M02_CF_Diagram.css index fc9c8ee8f..467e29d7a 100644 --- a/M02_MasterTemplete/CostFlow/M02_CF_Diagram.css +++ b/M02_MasterTemplete/CostFlow/M02_CF_Diagram.css @@ -256,3 +256,21 @@ stroke-width: 2; stroke-dasharray: 5 4; } + +/* 함수 상자 흐린 「+」 받는 자리 */ +.m02-cfdg__pill.is-plus { + justify-content: center; + min-width: 2rem; + border-style: dashed; + border-color: var(--color-text-muted); + background: transparent; + color: var(--color-text-muted); + opacity: 0.7; +} + +.m02-cfdg__pill.is-plus:hover, +.m02-cfdg.is-linking .m02-cfdg__pill.is-plus.is-target { + opacity: 1; + border-color: var(--cfdg-in); + color: var(--cfdg-in); +} diff --git a/M02_MasterTemplete/CostFlow/M02_CF_Diagram.ts b/M02_MasterTemplete/CostFlow/M02_CF_Diagram.ts index d8f55a7b5..e4febfee2 100644 --- a/M02_MasterTemplete/CostFlow/M02_CF_Diagram.ts +++ b/M02_MasterTemplete/CostFlow/M02_CF_Diagram.ts @@ -53,6 +53,10 @@ const TEXT = { From: ["어디서", "From"], To: ["어디로", "To"], Value: ["지금 값", "Now"], + PlusIn: [ + "받는 자리 — 내보내는 알약을 여기에 놓으면 받는 알약이 생김", + "Drop an output pill here to add an input", + ], LineOf: ["선 · {a} → {b}", "Line · {a} → {b}"], Hint: [ "상자 머리를 끌어 옮김 · 빈 곳 끌어 판 옮김 · 휠로 확대", @@ -124,6 +128,9 @@ export function resolveDrop( if (target.box === from.box) return { why: tt("SameBox") }; if (target.dir === "out") return { why: tt("NotIn") }; const box = lg.boxes.find((b) => b.id === target.box); + // 함수 상자 몸통 · 「+」 받는 자리 — 보낸 알약 이름으로 받는 알약을 새로(Model 이 만들고 이름이 겹치면 「이름 (2)」) + if (box?.kind === "function" && target.name === null) + return { to: { box: box.id, name: from.name } }; const ins = box ? pillsOf(box).ins : []; if (!box || !ins.length) return { why: tt("NoIn") }; const name = @@ -300,6 +307,28 @@ export function renderDiagram( return btn; }; + /** 함수 상자 왼쪽 흐린 「+」 — 여기(또는 몸통)에 놓으면 받는 알약이 새로 생김 */ + const plusSlot = (b: CfBox): HTMLElement => { + const slot = el("button", { + className: `m02-cfdg__pill is-in is-plus${st.linking && st.linking.box !== b.id ? " is-target" : ""}`, + text: "+", + attrs: { + type: "button", + "data-box": b.id, + "data-dir": "in", + "data-plus": "1", + title: tt("PlusIn"), + }, + }); + slot.addEventListener("click", (ev) => { + ev.stopPropagation(); + if (st.linking) return tryLink(st.linking, { box: b.id, name: null, dir: "in" }); + st.note = tt("PlusIn"); + redraw(); + }); + return slot; + }; + const boxEl = (b: CfBox): HTMLElement => { const { ins, outs } = pillsOf(b); const head = el("div", { @@ -323,7 +352,13 @@ export function renderDiagram( el("div", { className: "m02-cfdg__pills", children: [ - el("div", { className: "m02-cfdg__ins", children: ins.map((p) => pillEl(b, p, "in")) }), + el("div", { + className: "m02-cfdg__ins", + children: [ + ...ins.map((p) => pillEl(b, p, "in")), + ...(b.kind === "function" ? [plusSlot(b)] : []), + ], + }), el("div", { className: "m02-cfdg__outs", children: outs.map((p) => pillEl(b, p, "out")), From 1004768037c7ecee23bac74be6fd2d344c2a876a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 16:22:20 +0900 Subject: [PATCH 5/5] =?UTF-8?q?feat(m02-cf):=20=ED=95=A8=EC=88=98=20?= =?UTF-8?q?=EC=83=81=EC=9E=90=EC=97=90=20=EB=86=93=EC=9C=BC=EB=A9=B4=20?= =?UTF-8?q?=EB=B0=9B=EB=8A=94=20=EC=95=8C=EC=95=BD=EC=9D=84=20=EC=83=88?= =?UTF-8?q?=EB=A1=9C=20=EB=A7=8C=EB=93=A4=EC=96=B4=20=EC=9D=B4=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - link: 받는 쪽이 함수 상자이고 그 이름 받는 알약이 없으면 새로 만듦(빈 이름이면 보낸 알약 이름 · 같으면 「이름 (2)」) · func.inputs 에 칸 없이 더함 - unlink · removeBox: 받는 알약이 칸에 안 놓였고 다른 선도 없으면 같이 지움 - 검사: 칸에 놓지 않은 받는 알약 알림 - 시험 13개 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk --- M02_MasterTemplete/CostFlow/M02_CF_Eval.ts | 6 +++ M02_MasterTemplete/CostFlow/M02_CF_Model.ts | 52 ++++++++++++++++----- resources/tester/test_m02_cf_model.py | 50 ++++++++++++++++++++ 3 files changed, 96 insertions(+), 12 deletions(-) diff --git a/M02_MasterTemplete/CostFlow/M02_CF_Eval.ts b/M02_MasterTemplete/CostFlow/M02_CF_Eval.ts index 4d0679995..7c3209560 100644 --- a/M02_MasterTemplete/CostFlow/M02_CF_Eval.ts +++ b/M02_MasterTemplete/CostFlow/M02_CF_Eval.ts @@ -53,6 +53,10 @@ const TEXT = { ], NoCell: ["「{box}」 의 출력 칸이 정해지지 않음", "Output cell of 「{box}」 is not set"], NoOutput: ["출력 상자가 없음", "There is no output box"], + NoInCell: [ + "「{box}」 의 받는 알약 「{pill}」 을 칸에 놓지 않음", + "Pill 「{pill}」 of 「{box}」 is not placed in a cell", + ], NoValue: ["「{box}」 의 「{pill}」 값이 아직 없음", "「{pill}」 of 「{box}」 has no value yet"], } as const; @@ -193,6 +197,8 @@ export function checkLogic(lg: CfLogic, values: CfValues = {}): string[] { out.push(tl("EmptyIn", { box: b.name, pill: p.name })); if (b.kind === "function" && !parseA1(b.func?.output.cell ?? "")) out.push(tl("NoCell", { box: b.name })); + for (const i of b.func?.inputs ?? []) + if (!parseA1(i.cell)) out.push(tl("NoInCell", { box: b.name, pill: i.port })); } if (!lg.boxes.some((b) => b.kind === "output")) out.push(tl("NoOutput")); const ev = evalLogic(lg, values); diff --git a/M02_MasterTemplete/CostFlow/M02_CF_Model.ts b/M02_MasterTemplete/CostFlow/M02_CF_Model.ts index 949ad705a..f6ada09ca 100644 --- a/M02_MasterTemplete/CostFlow/M02_CF_Model.ts +++ b/M02_MasterTemplete/CostFlow/M02_CF_Model.ts @@ -201,11 +201,10 @@ export function addBox( /** 상자 지우기 — 이어진 선도 지움 */ export function removeBox(lg: CfLogic, id: string): CfLogic { - return { - ...lg, - boxes: lg.boxes.filter((b) => b.id !== id), - links: lg.links.filter((l) => l.from.box !== id && l.to.box !== id), - }; + // 선을 하나씩 끊어 받는 쪽 함수 상자의 칸 없는 알약도 정리 + let next = lg; + for (const l of lg.links) if (l.from.box === id || l.to.box === id) next = unlink(next, l.id); + return { ...next, boxes: next.boxes.filter((b) => b.id !== id) }; } export function moveBox(lg: CfLogic, id: string, x: number, y: number): CfLogic { @@ -267,7 +266,9 @@ export function reaches(lg: CfLogic, from: string, to: string): boolean { return walk(from); } -/** 선 잇기 — 입력 → 함수 · 출력 / 함수 → 함수 · 출력 · 출력에서 나감 없음 · 순환 · 같은 받는 자리 두 번 막음 */ +/** 선 잇기 — 입력 → 함수 · 출력 / 함수 → 함수 · 출력 · 출력에서 나감 없음 · 순환 · 같은 받는 자리 두 번 막음 + * 받는 쪽이 함수 상자이고 to.name 의 받는 알약이 없으면 새로 만듦(to.name 이 빈 글이면 보낸 알약 이름 · + * 같은 이름이 있으면 「이름 (2)」) · func.inputs 에 칸 없이(`cell: ""`) 더함 — 칸은 함수 판에서 놓음 */ export function link(lg: CfLogic, from: CfPort, to: CfPort): { lg: CfLogic; why: string | null } { const a = boxOf(lg, from.box); const b = boxOf(lg, to.box); @@ -275,21 +276,48 @@ export function link(lg: CfLogic, from: CfPort, to: CfPort): { lg: CfLogic; why: if (a.kind === "output") return { lg, why: tl("FromOutput") }; if (b.kind === "input") return { lg, why: tl("ToInput") }; if (a.id === b.id) return { lg, why: tl("Self") }; - if (!portsOut(a).some((p) => p.name === from.name) || !portsIn(b).some((p) => p.name === to.name)) - return { lg, why: tl("NoPill") }; + if (!portsOut(a).some((p) => p.name === from.name)) return { lg, why: tl("NoPill") }; + const has = portsIn(b).some((p) => p.name === to.name); + if (!has && b.kind !== "function") return { lg, why: tl("NoPill") }; if (reaches(lg, b.id, a.id)) return { lg, why: tl("Loop") }; - if (lg.links.some((l) => l.to.box === to.box && l.to.name === to.name)) + if (has && lg.links.some((l) => l.to.box === to.box && l.to.name === to.name)) return { lg, why: tl("Taken") }; + let next = lg; + let port = to.name; + if (!has) { + const used = portsIn(b).map((p) => p.name); + const base = to.name.trim() || from.name; + port = used.includes(base) ? uniqueSuffix(used, base) : base; + const func = b.func!; + next = updateBox(lg, b.id, { func: { ...func, inputs: [...func.inputs, { port, cell: "" }] } }); + } const id = nextId( "l", - lg.links.map((l) => l.id), + next.links.map((l) => l.id), ); return { - lg: { ...lg, links: [...lg.links, { id, from: { ...from }, to: { ...to } }] }, + lg: { + ...next, + links: [...next.links, { id, from: { ...from }, to: { box: to.box, name: port } }], + }, why: null, }; } +/** 같은 이름이면 「이름 (2)」 「이름 (3)」 */ +function uniqueSuffix(used: string[], base: string): string { + for (let n = 2; ; n++) if (!used.includes(`${base} (${n})`)) return `${base} (${n})`; +} + +/** 선 끊기 — 받는 쪽이 함수 상자이고 그 받는 알약이 칸에 안 놓였으면(cell 빈 글) 알약도 같이 지움 */ export function unlink(lg: CfLogic, id: string): CfLogic { - return { ...lg, links: lg.links.filter((l) => l.id !== id) }; + const gone = lg.links.find((l) => l.id === id); + const next = { ...lg, links: lg.links.filter((l) => l.id !== id) }; + const box = gone && boxOf(next, gone.to.box); + if (!gone || !box?.func) return next; + const port = box.func.inputs.find((i) => i.port === gone.to.name); + const still = next.links.some((l) => l.to.box === box.id && l.to.name === gone.to.name); + if (!port || port.cell.trim() || still) return next; + const inputs = box.func.inputs.filter((i) => i !== port); + return updateBox(next, box.id, { func: { ...box.func, inputs } }); } diff --git a/resources/tester/test_m02_cf_model.py b/resources/tester/test_m02_cf_model.py index 8072c83af..0e9ea8cce 100644 --- a/resources/tester/test_m02_cf_model.py +++ b/resources/tester/test_m02_cf_model.py @@ -272,3 +272,53 @@ def test_알약_이름_규칙() -> None: "out(M.portsOut(lg.boxes[0]).map(p => p.name));" ) assert got == ["시멘트", "표", "길이"] + + +def test_새_함수_상자에_놓으면_받는_알약이_생김() -> None: + got = _run( + ONE + "r = add(lg, 'function'); lg = r.lg; const g = r.box;\n" + "let a = M.link(lg, { box: man.id, name: '석공' }, { box: g.id, name: '' });\n" + "let b = M.link(a.lg, { box: area.id, name: '면적' }, { box: g.id, name: '' });\n" + "let c = M.link(b.lg, { box: f.id, name: '노무비' }, { box: g.id, name: '석공' });\n" + "let d = M.link(c.lg, { box: f.id, name: '노무비' }, { box: g.id, name: '' });\n" + "const gb = (x) => x.boxes.find(y => y.id === g.id);\n" + "const inputs = gb(d.lg).func.inputs;\n" + "const check = E.checkLogic(d.lg, V).filter(x => x.includes('칸에 놓지 않음'));\n" + "const placed = M.updateBox(d.lg, g.id, { func: { ...gb(d.lg).func, inputs: inputs.map(i => i.port === '면적' ? { ...i, cell: 'B2' } : i) } });\n" + "const cutAll = placed.links.filter(l => l.to.box === g.id).reduce((x, l) => M.unlink(x, l.id), placed);\n" + "const removed = M.removeBox(d.lg, man.id);\n" + "out([a.why, b.why, c.why, d.why, inputs, M.portsIn(gb(d.lg)).map(p => p.name), d.lg.links.slice(-2).map(l => l.to)," + " check, gb(cutAll).func.inputs, gb(removed).func.inputs.map(i => i.port)," + " M.link(lg, { box: man.id, name: '석공' }, { box: o.id, name: '없음' }).why]);" + ) + a, b, c, d, inputs, ports, last_to, check, cut_all, removed, to_output = got + assert (a, b, c, d) == (None, None, "이미 선이 들어온 자리", None) + assert inputs == [ + {"port": "석공", "cell": ""}, + {"port": "면적", "cell": ""}, + {"port": "노무비", "cell": ""}, + ] + assert ports == ["석공", "면적", "노무비"] + assert last_to == [{"box": "b5", "name": "면적"}, {"box": "b5", "name": "노무비"}] + assert check == [ + "「함수 2」 의 받는 알약 「석공」 을 칸에 놓지 않음", + "「함수 2」 의 받는 알약 「면적」 을 칸에 놓지 않음", + "「함수 2」 의 받는 알약 「노무비」 을 칸에 놓지 않음", + ] + assert cut_all == [{"port": "면적", "cell": "B2"}] # 칸에 놓인 것만 남음 + assert removed == ["면적", "노무비"] # 석공 상자를 지우면 칸 없는 「석공」 받는 알약도 지움 + assert to_output == "알약이 없음" # 출력 상자는 받는 알약을 새로 만들지 않음 + + +def test_같은_이름_받는_알약은_번호() -> None: + got = _run( + ONE + "r = add(lg, 'function'); lg = r.lg; const g = r.box;\n" + "let x = M.link(lg, { box: man.id, name: '석공' }, { box: g.id, name: '' }).lg;\n" + "x = M.link(x, { box: f.id, name: '노무비' }, { box: g.id, name: '' }).lg;\n" + "r = add(x, 'input'); x = r.lg; const m2 = r.box;\n" + "x = M.updateBox(x, m2.id, { input: { mode: 'group', group: '인력', elements: [{ ref: 'LB1', name: '석공', spec: '', unit: '인' }], variables: [] } });\n" + "x = M.link(x, { box: m2.id, name: '석공' }, { box: g.id, name: '' }).lg;\n" + "x = M.link(x, { box: area.id, name: '면적' }, { box: g.id, name: '할증' }).lg;\n" + "out(x.boxes.find(y => y.id === g.id).func.inputs.map(i => i.port));" + ) + assert got == ["석공", "노무비", "석공 (2)", "할증"]