From 817826c1afb6d35e8cf98e47606209a3a831963b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 13:10:49 +0900 Subject: [PATCH] =?UTF-8?q?feat(m01-sl):=20=ED=95=AD=EB=AA=A9=20=EC=83=81?= =?UTF-8?q?=EC=9E=90=20=ED=8B=80=EC=9D=84=20Item=20=EB=85=B8=EB=AC=B4=20?= =?UTF-8?q?=ED=8B=80=EA=B3=BC=20=ED=95=9C=20=EB=B2=8C=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - addNode(item) = M01_SL_Item moneyLayout(노무)과 같은 칸 — B1 수량 · B2~B4 노무/재료/경비 단가(노무 B2 만 바깥 값) · B5 단가 · B6 금액 · B7~B9 노무비/재료비/경비 · B10 사용 수량 · 머리 줄 없음 - 출력 알약 단가 · 금액 · 노무비 · 재료비 · 경비 · 사용 수량 · node.ref = {"what":"노무"} - 시험 14개(틀 칸 대조 더함) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk --- M01_MasterData/SheetLogic/M01_SL_Model.ts | 82 +++++++++++++++++------ resources/tester/test_m01_sl_model.py | 63 ++++++++++++----- 2 files changed, 108 insertions(+), 37 deletions(-) diff --git a/M01_MasterData/SheetLogic/M01_SL_Model.ts b/M01_MasterData/SheetLogic/M01_SL_Model.ts index 746509c34..d457e5676 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Model.ts +++ b/M01_MasterData/SheetLogic/M01_SL_Model.ts @@ -118,6 +118,13 @@ const TEXT = { PriceNote: ["바깥 값 — 열 때 넣음", "Outside value — filled on open"], LogicNote: ["다른 로직 결과 — 열 때 넣음", "Other logic's result — filled on open"], Sum: ["계", "Total"], + PriceLabor: ["노무 단가", "Labor price"], + PriceMat: ["재료 단가", "Material price"], + PriceExp: ["경비 단가", "Expense price"], + Labor3: ["노무비", "Labor cost"], + Mat3: ["재료비", "Material cost"], + Exp3: ["경비", "Expense"], + Won: ["원", "KRW"], NoPill: ["알약이 없음", "No such pill"], Self: ["같은 상자끼리는 못 이음", "A box can't link to itself"], Loop: ["이으면 돌고 도는 선이 됨", "This line would make a loop"], @@ -136,6 +143,9 @@ const tl = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex == /** 비목 이름(결과 · 항목 칸) — 화면 글 아님 · 알약 이름 열쇠 */ const COSTS = ["노무비", "재료비", "경비"] as const; +/** 일위대가표가 읽는 출력 알약 이름(Item `USED` 와 같음) */ +const USED = "사용 수량"; + /* ── 작은 도움 ──────────────────────────────────────────────────────────── */ const STAGE: Record = { @@ -228,24 +238,8 @@ function template(kind: SlKind, name: string): { rows: Row[]; ins: SlPill[]; out outs: [{ name: tl("Found"), cell: "B4" }], }; case "item": - return { - rows: [ - [tl("Qty"), null, tl("QtyNote")], - [tl("Price"), null, tl("PriceNote")], - [tl("Amount"), f("B2*B3"), ""], - ["노무비", f("B4"), ""], - ["재료비", v(0), ""], - ["경비", v(0), ""], - ], - ins: [ - { name: tl("Qty"), cell: "B2" }, - { name: tl("Price"), cell: "B3", outside: true }, - ], - outs: [ - { name: tl("Amount"), cell: "B4" }, - ...COSTS.map((c, i) => ({ name: c, cell: `B${5 + i}` })), - ], - }; + // 항목 틀은 머리 줄 없음 — Item(sub5) 노무 틀과 한 벌(`itemSheet`) + return { rows: [], ins: [], outs: [] }; case "logic": return { rows: [[tl("Found"), null, tl("LogicNote")]], @@ -266,6 +260,52 @@ function template(kind: SlKind, name: string): { rows: Row[]; ins: SlPill[]; out } } +/** 항목 상자 틀 — `M01_SL_Item.ts` moneyLayout(노무)과 같은 칸 · 같은 알약(종류를 바꾸면 Item 이 다시 채움) + * B1 수량 · B2~B4 노무/재료/경비 단가(노무는 B2 만 바깥 값) · B5 단가 · B6 금액 · B7~B9 비목 · B10 사용 수량 */ +function itemSheet(): { cells: Record; rows: Row[]; ins: SlPill[]; outs: SlPill[] } { + const won = tl("Won"); + const labels: [string, string][] = [ + ["A1", tl("Qty")], + ["A2", tl("PriceLabor")], + ["A3", tl("PriceMat")], + ["A4", tl("PriceExp")], + ["A5", tl("Price")], + ["A6", tl("Amount")], + ["A7", tl("Labor3")], + ["A8", tl("Mat3")], + ["A9", tl("Exp3")], + ["A10", USED], + ]; + const cells: Record = Object.fromEntries(labels.map(([k, v]) => [k, { 값: v }])); + Object.assign(cells, { + B3: { 값: 0 }, + B4: { 값: 0 }, + B5: { 식: "B2+B3+B4" }, + B6: { 식: "ROUND(B1*B5,1)" }, + B7: { 식: "ROUND(B1*B2,1)" }, + B8: { 식: "ROUND(B1*B3,1)" }, + B9: { 식: "ROUND(B1*B4,1)" }, + B10: { 식: "B1" }, + }); + const out = (name: string, cell: string, unit = won): SlPill => ({ name, cell, unit }); + return { + cells, + rows: [], + ins: [ + { name: tl("Qty"), cell: "B1", unit: "" }, + { name: tl("PriceLabor"), cell: "B2", unit: won, outside: true }, + ], + outs: [ + out(tl("Price"), "B5"), + out(tl("Amount"), "B6"), + out(tl("Labor3"), "B7"), + out(tl("Mat3"), "B8"), + out(tl("Exp3"), "B9"), + out(USED, "B10", ""), + ], + }; +} + /* ── 만들기 · 상자 ───────────────────────────────────────────────────────── */ /** 빈 시트 로직 */ @@ -293,8 +333,9 @@ export function addNode( book.시트.map((s) => s.id), ); const title = uniqueName(book, name); - const t = template(kind, title); - const 칸: Record = { + const item = kind === "item" ? itemSheet() : null; + const t = item ?? template(kind, title); + const 칸: Record = item?.cells ?? { A1: { 값: tl("Name") }, B1: { 값: tl("Value") }, C1: { 값: tl("Note") }, @@ -316,6 +357,7 @@ export function addNode( sheetId, pillsIn: t.ins, pillsOut: t.outs, + ...(item ? { ref: JSON.stringify({ what: "노무" }) } : {}), stage: STAGE[kind], }; out.nodes.push(node); diff --git a/resources/tester/test_m01_sl_model.py b/resources/tester/test_m01_sl_model.py index 204c9fcb9..ee944877a 100644 --- a/resources/tester/test_m01_sl_model.py +++ b/resources/tester/test_m01_sl_model.py @@ -71,7 +71,7 @@ let sl = M.newLogic("시험 하나", "원/㎡"); let r = M.addNode(sl, "input", "품"); sl = r.sl; const pum = r.node; r = M.addNode(sl, "item", "보통 인부"); sl = r.sl; const man = r.node; man.ref = "LB000002"; r = M.addNode(sl, "result", "결과"); sl = r.sl; const res = r.node; -sl = E.applyOutside(sl, [{ node: pum.id, cell: "B2", value: 0.5 }, { node: man.id, cell: "B3", value: 100000 }]); +sl = E.applyOutside(sl, [{ node: pum.id, cell: "B2", value: 0.5 }, { node: man.id, cell: "B2", value: 100000 }]); sl = link(sl, pum, "품", man, "수량"); sl = link(sl, man, "노무비", res, "노무비"); """ @@ -87,7 +87,7 @@ r = M.addNode(sl, "item", "보통인부"); sl = r.sl; const man = r.node; r = M.addNode(sl, "result", "결과"); sl = r.sl; const res = r.node; sl = E.applyOutside(sl, [ { node: pum.id, cell: "B2", value: 0.2 }, { node: rate.id, cell: "B2", value: 1.3 }, - { node: stone.id, cell: "B3", value: 200000 }, { node: man.id, cell: "B3", value: 100000 }, + { node: stone.id, cell: "B2", value: 200000 }, { node: man.id, cell: "B2", value: 100000 }, ]); sl = link(sl, pum, "품", calc, "값1"); sl = link(sl, rate, "할증률", calc, "값2"); @@ -101,12 +101,12 @@ sl = link(sl, man, "노무비", res, "노무비"); def test_입력값_항목_결과_수식과_값() -> None: got = _run( ONE - + "out([sheet(sl, man.id).칸.B2, sheet(sl, res.id).칸.B2, E.evalLogic(sl), sl.edges, sl.nodes.map(n => [n.kind, n.stage])," + + "out([sheet(sl, man.id).칸.B1, sheet(sl, res.id).칸.B2, E.evalLogic(sl), sl.edges, sl.nodes.map(n => [n.kind, n.stage])," " sl.workbook.시트.map(s => s.이름)]);" ) qty, total_in, ev, edges, kinds, names = got assert qty == {"식": "품!B2"} - assert total_in == {"식": "'보통 인부'!B5"} + assert total_in == {"식": "'보통 인부'!B7"} assert ev["result"] == {"노무비": 50000, "재료비": None, "경비": None, "계": 50000} assert ev["pills"]["n2"]["금액"] == 50000 and ev["pillsIn"]["n2"]["수량"] == 0.5 assert ev["errors"] == [] @@ -132,7 +132,7 @@ def test_계산_두_항목_더함_값바뀜() -> None: # 석공 0.2×1.3×200000 = 52000 · 보통인부 0.2×100000 = 20000 assert a == {"노무비": 72000, "재료비": None, "경비": None, "계": 72000} assert c == {"결과": 0.26} - assert cell == {"식": "석공!B5+보통인부!B5"} + assert cell == {"식": "석공!B7+보통인부!B7"} assert b["계"] == 0.2 * 1.5 * 200000 + 20000 @@ -146,7 +146,7 @@ def test_선_끊기_다시_잇기() -> None: " E.evalLogic(c).result, a.edges.length]);" ) cell_a, ra, cell_b, check_b, rc, n_edges = got - assert cell_a == {"식": "보통인부!B5"} and ra["계"] == 20000 + assert cell_a == {"식": "보통인부!B7"} and ra["계"] == 20000 assert cell_b is None assert "결과 상자에 이어진 선이 없음" in check_b assert not any("입력 알약" in x for x in check_b) @@ -202,7 +202,7 @@ def test_바깥값_칸목록과_표_덩어리() -> None: ) cells, e1, f2, e3, f3, lg, check = got assert cells == [ - {"id": "n2!B3", "node": "n2", "cell": "B3", "kind": "price", "ref": "LB000002"}, + {"id": "n2!B2", "node": "n2", "cell": "B2", "kind": "price", "ref": "LB000002"}, { "id": "n4!E1", "node": "n4", @@ -230,16 +230,16 @@ def test_알약_정하기_이름바꾸면_선이_따라감() -> None: ONE + "let s2 = M.setPill(sl, pum.id, 'B2', 'out', '품셈 품');\n" "const e = s2.edges[0].from;\n" "s2 = M.setPill(s2, pum.id, 'D2', 'out', '품셈 품');\n" - "const moved = sheet(s2, man.id).칸.B2;\n" + "const moved = sheet(s2, man.id).칸.B1;\n" "const s3 = M.removePill(s2, pum.id, 'out', '품셈 품');\n" "const s4 = M.setPill(sl, man.id, 'C9', 'in', '할증');\n" - "out([e, moved, s3.edges.length, sheet(s3, man.id).칸.B2 ?? null, s4.nodes[1].pillsIn.map(p => [p.name, p.cell])]);" + "out([e, moved, s3.edges.length, sheet(s3, man.id).칸.B1 ?? null, s4.nodes[1].pillsIn.map(p => [p.name, p.cell])]);" ) e, moved, n_edges, cleared, ins = got assert e == {"node": "n1", "pill": "품셈 품"} assert moved == {"식": "품!D2"} assert n_edges == 1 and cleared is None - assert ins == [["수량", "B2"], ["단가", "B3"], ["할증", "C9"]] + assert ins == [["수량", "B1"], ["노무 단가", "B2"], ["할증", "C9"]] def test_상자_지우기() -> None: @@ -250,7 +250,7 @@ def test_상자_지우기() -> None: ) names, sheets, n_edges, cell, total, before = got assert names == ["품", "할증률", "할증 품", "보통인부", "결과"] and sheets == names - assert n_edges == 4 and cell == {"식": "보통인부!B5"} and total == 20000 + assert n_edges == 4 and cell == {"식": "보통인부!B7"} and total == 20000 assert before == 6 # 받은 것은 안 바뀜 @@ -288,11 +288,11 @@ def test_상자_이름_바꾸기_수식이_따라감() -> None: ) why, name, sheet_name, cell, total, tries, before = got assert why is None and name == sheet_name == "석공 (돌쌓기)" - assert cell == {"식": "'석공 (돌쌓기)'!B5+보통인부!B5"} and total == 72000 + assert cell == {"식": "'석공 (돌쌓기)'!B7+보통인부!B7"} and total == 72000 assert tries == [ "같은 이름 상자가 있음", "이름은 1~31 글자", - "이름에 못 쓰는 글자 — [ ] : * ? / \ 와 앞뒤 '", + "이름에 못 쓰는 글자 — [ ] : * ? / \\ 와 앞뒤 '", "상자가 없음", "이름은 1~31 글자", ] @@ -316,7 +316,7 @@ def test_바깥값_답을_넣을값으로_범위머리() -> None: "const cells = E.outsideCells(sl);\n" "const head = M.rangeHead('높이');\n" "const got = E.answersToValues(cells, [\n" - " { id: 'n2!B3', ok: true, value: 172068 },\n" + " { id: 'n2!B2', ok: true, value: 172068 },\n" " { id: 'n4!E1', ok: true, rows: [[...head, '증가율'], [3, 4, 30]] },\n" "]);\n" "const s2 = E.applyOutside(sl, got.values);\n" @@ -325,10 +325,39 @@ def test_바깥값_답을_넣을값으로_범위머리() -> None: head, got_values, f1, g2, total = got assert head == ["높이·아래", "높이·위"] assert got_values["errors"] == [] - assert got_values["values"][0] == {"node": "n2", "cell": "B3", "value": 172068} + assert got_values["values"][0] == {"node": "n2", "cell": "B2", "value": 172068} assert (f1, g2, total) == ({"값": "높이·위"}, {"값": 30}, 86034) missing = _run( ONE - + "out(E.answersToValues(E.outsideCells(sl), [{ id: 'n2!B3', ok: false, reason: '없는 직종' }]));" + + "out(E.answersToValues(E.outsideCells(sl), [{ id: 'n2!B2', ok: false, reason: '없는 직종' }]));" ) - assert missing == {"values": [], "errors": ["B3 칸 바깥 값을 못 받음 없는 직종"]} + assert missing == {"values": [], "errors": ["B2 칸 바깥 값을 못 받음 없는 직종"]} + + +def test_항목_틀은_Item_노무_틀과_같은_칸() -> None: + got = _run( + "let sl = M.newLogic(); const r = M.addNode(sl, 'item', '석공');" + " out([sheet(r.sl, r.node.id).칸, r.node.pillsIn, r.node.pillsOut.map(p => [p.name, p.cell]), r.node.ref]);" + ) + cells, ins, outs, ref = got + assert {k: v for k, v in cells.items() if k.startswith("B")} == { + "B3": {"값": 0}, + "B4": {"값": 0}, + "B5": {"식": "B2+B3+B4"}, + "B6": {"식": "ROUND(B1*B5,1)"}, + "B7": {"식": "ROUND(B1*B2,1)"}, + "B8": {"식": "ROUND(B1*B3,1)"}, + "B9": {"식": "ROUND(B1*B4,1)"}, + "B10": {"식": "B1"}, + } + assert [cells[f"A{i}"]["값"] for i in range(1, 11)] == [ + "수량", "노무 단가", "재료 단가", "경비 단가", "단가", "금액", "노무비", "재료비", "경비", "사용 수량", + ] # fmt: skip + assert ins == [ + {"name": "수량", "cell": "B1", "unit": ""}, + {"name": "노무 단가", "cell": "B2", "unit": "원", "outside": True}, + ] + assert outs == [ + ["단가", "B5"], ["금액", "B6"], ["노무비", "B7"], ["재료비", "B8"], ["경비", "B9"], ["사용 수량", "B10"], + ] # fmt: skip + assert ref == '{"what":"노무"}'