feat(m01-sl): 항목 상자 틀을 Item 노무 틀과 한 벌로

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk
This commit is contained in:
2026-10-01 13:10:49 +09:00
co-authored by Claude Opus 5.5
parent 66925bdfad
commit 817826c1af
2 changed files with 108 additions and 37 deletions
+62 -20
View File
@@ -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<SlKind, number> = {
@@ -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<string, Cell>; 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<string, Cell> = 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<string, Cell> = {
const item = kind === "item" ? itemSheet() : null;
const t = item ?? template(kind, title);
const 칸: Record<string, Cell> = 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);
+46 -17
View File
@@ -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":"노무"}'