feat(780-2): 엑셀 보기 단가산출 탭 오른쪽 = 단가산출 목록 — 판 바꾸기 · 거르기 · 목록 ↔ 시트 · 두 벌 맞춤(요구 627)

- 새 Hopyo_SheetSk: 탭(역할) 따라 오른쪽 판 바꿈 · 일위대가표 = 호표 목록 그대로 · 단가산출표 · 상세 = 마법사 단가산출 칸 좁은 꼴 한 벌(처음 올 때 지음 · 다른 층 · 권한 없음 = 보기만) · 시트 칸 → 목록 강조 · 목록 줄 → 지금 탭 블록 자리
- WizardSk: 좁은 꼴 옵션 · 부름 칸(일위대가 n 곳 · 일위대가 밖) · 거르기(전체 · 안 · 밖 · 고를 줄 있음) · 같은 캐시 두 벌 맞춤 알림 · 옮김 뒤 HOPYO_BUILT(열린 엑셀 보기 칠 다시)
- SkPick: 줄 0 블록 = 「줄 없음」 확인 막음 · 거르기 · 끝내기 글 재료에 일위대가 밖 수
- SheetNav: role · blockAt · spotOf 내보냄 · SheetMap: 판정 없음 글 → 「일위대가 밖」
- Wizard [끝내기]: 막지 않음 · 물음창에 안 옮긴 단가산출 n(일위대가 밖 m)
- 시험: 새 test_627_sheet_sk(판 바꿈 · 목록 지음 · 줄 0 막음 · 캐시 공유) · test_778_5 끝내기 재료 꼴에 밖 칸 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-04 18:01:06 +09:00
co-authored by Claude Opus 5.5
parent 34288c1980
commit 8a5689c400
12 changed files with 503 additions and 47 deletions
+87 -3
View File
@@ -1,11 +1,40 @@
/* 778-5 D-5 마법사 단가산출 줄 고름 헬퍼 — `logic/Hopyo_SkPick`(순수) · `Hopyo_SkApi`(몸) 를 트랜스파일해 Node 로 돌림 ·
* fetch 대역(부른 길 · 몸 모음) · stdin = {blocks} · stdout = JSON. */
* fetch 대역(부른 길 · 몸 모음) · stdin = {blocks} · stdout = JSON.
* 780-2 — stdin `{sheetSk: {...}}` = 엑셀 보기 오른쪽 판 바꾸기(`Hopyo_SheetSk`) · 단가산출 칸 · 시트 막대 · 판은 가짜. */
const fs = require("fs");
const path = require("path");
const { makeLoader } = require("./helper_ts_stub_loader.cjs");
const ROOT = path.join(__dirname, "..", "..");
const STUBS = { "@config/config_frontend": { API_BASE_URL: "/api", ROUTES: {} } };
const made = []; // 가짜 단가산출 칸 — 지은 인자 · 강조
const fakeEl = () => ({
hidden: false,
attrs: {},
kids: [],
setAttribute(k, v) {
this.attrs[k] = v;
},
append(x) {
this.kids.push(x);
},
});
const STUBS = {
"@config/config_frontend": { API_BASE_URL: "/api", ROUTES: {} },
"@ui/ui_template_elements": {
el: (tag, o = {}) => ({ ...fakeEl(), tag, className: o.className }),
},
"./M02_MasterTemplete_Hopyo_WizardSk": {
sangeunStep: (scope, key, file, onChange, o) => {
const step = {
args: { scope, key, file, o: { ...o, onRow: !!o.onRow } },
marks: [],
onRow: o.onRow,
};
made.push(step);
return { root: fakeEl(), mark: (n) => step.marks.push(n) };
},
},
};
const stub = new Proxy({}, { get: () => () => {} });
const { loadTs } = makeLoader({
root: ROOT,
@@ -19,7 +48,9 @@ const { loadTs } = makeLoader({
global.sessionStorage = { getItem: () => null, setItem: () => {}, removeItem: () => {} };
(async () => {
const { blocks } = JSON.parse(fs.readFileSync(0, "utf8"));
const input = JSON.parse(fs.readFileSync(0, "utf8"));
if (input.sheetSk) return process.stdout.write(JSON.stringify(sheetSk(input.sheetSk)));
const { blocks } = input;
const calls = [];
global.fetch = async (url, init = {}) => {
calls.push({
@@ -77,8 +108,61 @@ global.sessionStorage = { getItem: () => null, setItem: () => {}, removeItem: ()
await api.judgeSangeun({ layer: "personal", owner: null }, "HP-000001");
await api.judgeSangeun({ layer: "personal", owner: null }, "HP-000001", out.body);
out.calls = calls;
// 780-2 일위대가 안 · 밖 · 줄 0 블록 — 거르기 · 판정 · 확인 막음 · 끝내기 글 재료
const more = [
{ ...a, 부름: [3, 7] },
{ ...b, 부름: [] },
{ ...b, 번: 9, 부름: [], 줄: [] },
{ ...c, 부름: [12], 만든: "SK-000001" },
];
const e = more[2];
let d2 = sk.setSure(sk.emptyDraft(), e, true);
out.empty = {
판정: sk.verdictOf(e, {}),
켜짐: sk.canConfirm(e, {}),
확인: d2.확인,
몸: sk.sureBody({ 고름: {}, 확인: [9] }, more),
};
d2 = sk.emptyDraft();
out.filter = Object.fromEntries(
["all", "in", "out", "open"].map((f) => [f, sk.filterBlocks(more, d2, f).map((x) => x.번)]),
);
out.old = sk.filterBlocks(blocks, d2, "out").length; // 옛 서버(부름 없음) = 모두 안
out.wait2 = sk.waiting(d2, more);
process.stdout.write(JSON.stringify(out));
})().catch((error) => {
process.stderr.write(String(error?.stack ?? error));
process.exit(1);
});
/** 780-2 판 바꾸기 — 탭(역할) 차례로 고름 · 판 숨김 · 칸 지음 한 번 · 강조 · 줄 누름 뛰기 */
function sheetSk({ same, canWrite, picks }) {
const M02 = path.join(ROOT, "M02_MasterTemplete");
const { createSheetSk } = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_SheetSk.ts"));
const roles = { s1: "일위대가표", s2: "단가산출표", s3: "단가산출상세" };
const jumps = [];
const nav = {
role: (sheet) => roles[sheet],
blockAt: (sheet, row) =>
sheet === "s2" ? row - 4 : sheet === "s3" ? Math.floor(row / 10) : null,
spotOf: (no, role) => ({ 시트: role === "단가산출표" ? "s2" : "s3", 행: [no, no + 1] }),
jumpTo: (sheet, rows) => jumps.push([sheet, rows]),
};
const toggled = [];
const side = {
hidden: false,
after: () => {},
parentElement: { classList: { toggle: (c, on) => toggled.push([c, on]) } },
};
const scope = { layer: "personal", owner: null };
const target = same ? scope : { layer: "company", owner: null };
const p = createSheetSk({ scope, key: "HP-000001", 파일: "a.xls", target, canWrite, nav, side });
const seen = [];
for (const [sheet, cell] of picks) {
p.at({ 시트: sheet, 칸: cell });
seen.push({ 호표판숨김: side.hidden, 단가판숨김: p.root.hidden, 지음: made.length });
if (sheet === "s2" && made[0]) made[0].onRow(8); // 목록 줄 누름(단가산출표 탭)
if (sheet === "s3" && made[0]) made[0].onRow(8); // 상세 탭
}
return { seen, toggled, made: made.map((m) => ({ args: m.args, marks: m.marks })), jumps };
}
+99
View File
@@ -0,0 +1,99 @@
"""780-2 지킴 — 엑셀 보기 단가산출표 · 상세 탭 = 오른쪽 단가산출 목록(요구 627).
서버 없이 잡음: 탭(역할) 따라 판 바꿈(일위대가표 = 호표 목록 · 단가산출 탭 = 단가산출 목록 · 칸은 처음 한 번만 지음) ·
시트 행 → 목록 강조 · 목록 줄 → 지금 탭의 블록 자리 · 만들 층 ≠ 파일 층 = 보기만 · 거르기(전체 · 안 · 밖 · 고를 줄 있음) ·
줄 0 블록 = 「줄 없음」 확인 막음 · 마법사 칸과 같은 캐시 열쇠(두 벌 고름 맞춤) · [끝내기] = 막지 않고 물음창 글만 ·
화면 = 부품 상자만 · 700 줄.
"""
import json
import os
import re
import subprocess
from pathlib import Path
HERE = os.path.dirname(__file__)
M02 = Path(__file__).resolve().parents[2] / "M02_MasterTemplete"
from test_778_5_sk_pick_screen import BLOCKS as sys_blocks # noqa: E402
def _node(payload: dict) -> dict:
proc = subprocess.run(
["node", os.path.join(HERE, "helper_778_5_sk_pick.cjs")],
input=json.dumps(payload, ensure_ascii=False),
capture_output=True,
text=True,
encoding="utf-8",
check=False,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
PICKS = [["s1", "B5"], ["s2", "B12"], ["s1", "C3"], ["s3", "A85"], ["s3", "A2"]]
def test_판_바꿈_목록_지음_강조_뛰기():
got = _node({"sheetSk": {"same": True, "canWrite": True, "picks": PICKS}})
assert got["seen"] == [
{"호표판숨김": False, "단가판숨김": True, "지음": 0}, # 일위대가표 = 호표 목록 · 칸 안 지음
{"호표판숨김": True, "단가판숨김": False, "지음": 1}, # 단가산출표 = 단가산출 목록
{"호표판숨김": False, "단가판숨김": True, "지음": 1},
{"호표판숨김": True, "단가판숨김": False, "지음": 1}, # 다시 와도 한 번만 지음
{"호표판숨김": True, "단가판숨김": False, "지음": 1},
]
assert [on for _, on in got["toggled"]] == [False, True, False, True, True]
(step,) = got["made"]
assert step["args"]["o"] == {"narrow": True, "onRow": True, "readOnly": False}
assert step["args"]["key"] == "HP-000001"
assert step["marks"] == [8, 8, 0] # 목록 행 12 → 단산8 · 상세 85 행 → 단산8 · 2 행 → 블록 0
# 목록 줄 누름 = 지금 탭의 자리(단가산출표 = 목록 행 · 상세 = 블록 범위)
assert got["jumps"] == [["s2", [8, 9]], ["s3", [8, 9]], ["s3", [8, 9]]]
def test_다른_층_권한_없음_보기만():
for same, can in ((False, True), (True, False)):
got = _node({"sheetSk": {"same": same, "canWrite": can, "picks": [["s2", "B5"]]}})
assert got["made"][0]["args"]["o"]["readOnly"] is True
def test_거르기_줄0_막음_끝내기_글():
got = _node({"blocks": sys_blocks})
assert got["empty"] == {"판정": "줄 없음", "켜짐": False, "확인": [], "몸": []}
assert got["filter"] == {"all": [2, 5, 9, 7], "in": [2, 7], "out": [5, 9], "open": [2, 9]}
assert got["old"] == 0 # 옛 서버(부름 없음) = 모두 일위대가 안
assert got["wait2"] == {"블록": 3, "밖": 2, "줄": 3} # 만든 블록 7 뺌 · 줄 0 블록 = 고를 줄 1
def _src(name: str) -> str:
return (M02 / f"M02_MasterTemplete_Hopyo_{name}.ts").read_text(encoding="utf-8")
def test_캐시_공유_알림_칠_다시():
wiz = _src("WizardSk")
assert wiz.count("skDraftKey(scope, key)") == 1 # 두 벌 모두 같은 열쇠(층 · 주인 · 파일 키)
assert "SK_SYNC" in wiz and "tell({ got: next })" in wiz # 옮김 = 받은 판정 그대로 다른 벌에
assert wiz.count("tell();") == 2 # [확인] · 줄 고름 바뀜
assert "new Event(HOPYO_BUILT)" in wiz # 옮김 뒤 열린 엑셀 보기 칠 다시
sheet = _src("Sheet")
assert "createSheetSk(" in sheet and "sk?.at(s);" in sheet
side = _src("SheetSk")
assert "sangeunStep(opt.scope, opt.key" in side # 마법사와 같은 파일 층 · 키
nav = _src("SheetNav")
assert "blockAt(sheet: string, row: number)" in nav and "spotOf(no: number, role: Role)" in nav
wizard = _src("Wizard")
assert 'L("M02_SkUnmoved")' in wizard and "finish.disabled = !!n?.줄;" in wizard # 밖 = 안 막음
tone = (M02 / "logic" / "M02_MasterTemplete_Hopyo_SheetMap.ts").read_text(encoding="utf-8")
assert '글: "일위대가 밖"' in tone
def test_부품_상자만_700줄():
for name in ("SheetSk", "Sheet", "SheetNav", "WizardSk", "Wizard"):
src = _src(name)
assert len(src.splitlines()) <= 700, name
assert not re.search(r"#[0-9a-fA-F]{3,6}\b|rgb\(", src), name
for name in ("SheetSk", "WizardSk"):
assert not re.search(r'el\("(button|input|select)"|<button|<input', _src(name)), name
css = (M02 / "M02_MasterTemplete_Hopyo.css").read_text(encoding="utf-8")
block = css.split(".m02-hopyo-sheet.is-sk", 1)[1].split("/*", 1)[0]
assert not re.search(r"#[0-9a-fA-F]{3,6}\b|rgb\(", block)
@@ -92,7 +92,11 @@ def test_몸_꼴_이음_줄_자동_확인_없음():
assert first["method"] == "POST" and first["body"] == {"확인": []} # 판정만
assert first["url"].endswith("/layers/personal/hopyo/HP-000001/sangeun")
assert second["body"] == {"확인": got["body"]}
assert got["wait"] == {"블록": 3, "줄": 1} # 안 옮긴 블록 셋 · 블록 7 하위 줄
assert got["wait"] == {
"블록": 3,
"밖": 0,
"줄": 1,
} # 안 옮긴 블록 셋 · 블록 7 하위 줄 · 옛 서버 = 밖 없음
assert got["tidy"] == [5] # 고름 지우면 그 블록 확인 빠짐
assert got["round"]["확인"] == [2, 5] and got["bad"] == {"고름": {}, "확인": []}