feat(837-1): 일위대가 불러오기 층 가르기 걷음 — 누가 하든 자기 개인 층

- 화면: 왼쪽 층 고름(개인·회사·마스터) · 머리 층 배지 · 작업 중 줄의 층 글 걷음(Hopyo_Side · Hopyo_Import)
- 서버: 불러오기 길(Hopyo_Router)은 경로 층 · 주인 · 몸 층을 무시하고 개인 층 고정 · 단산 블록 → 가져온 식(from-hopyo · preview)도 개인 층
- 시험: 새 지킴 시험 test_837_1 · 낡은 시험 고침(785_4 층 고름 → 층 가르기 없음 · dirty 글 / 705_2 남의 층 403 → 경로 층 무시 200)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-15 14:02:33 +09:00
co-authored by Claude Sonnet 5.5
parent 9658783fab
commit a6b236f2b2
7 changed files with 92 additions and 99 deletions
+8 -2
View File
@@ -7,7 +7,7 @@
그대로 쓰는 길(목록 · 올림 · 엑셀 보기 머리 · 격자 · 지우기 · 모두 고정 · 붙여넣기 · 다시 잇기 ·
구조물 조합 · 칠 = B09 · M01 이 씀) · 옛 맞춤 길(호표 하나 맞춤 · 미리 셈 · 실무 호표 · 확인 만들기 · 안 이은 줄 ·
단가산출 만들기)은 785-7 에서 지움.
층 권한 = `Router_Items._root` · ⚠ 등록은 `main.py`(로그인만).
층 = 늘 개인(요구 855 · 위 `_root` 가 고정) · ⚠ 등록은 `main.py`(로그인만).
"""
from __future__ import annotations
@@ -33,11 +33,17 @@ from M02_MasterTemplete import M02_Hopyo_View as hopyo_view_
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_San_MatPick as mat_pick
from M02_MasterTemplete import M02_Sangeun_FromHopyo as from_hopyo
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import _root as _layer_root
router = APIRouter(prefix="/api/m02", tags=["M02 Hopyo"])
def _root(session: Session, layer: str, owner: str | None = None, write: bool = False) -> Path:
"""불러오기 자리 = 늘 자기 개인 층(요구 855) — 경로 층 · 주인 글 · 몸 층은 받되 무시(회사 · 마스터 · 남의 층 불가)."""
return _layer_root(session, "personal", None, write=write)
# ── [수정] 보기(809-1) — 경쟁사 로직 인정 · 요소만 연결 · 읽기만 ─────────────────
@@ -1,8 +1,8 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_Import.ts
* 일위대가 불러오기 본 칸 보기(PLAN 785-4 · 요구 642-11 — 마법사 `Hopyo_Wizard` 갈음 · 가운데 상세 페이지 없음) —
* 왼쪽 [불러오기] → 층 · 파일 고름 → 올림(`Hopyo_Side`) → 바로 이 보기:
* · 머리 막대 = 엑셀 보기 몸(`Hopyo_Sheet` — 시트 · 호표 · 검산 · 연결)의 머리에 층 · [끝내기]를 덧붙임
* 왼쪽 [불러오기] → 파일 고름 → 올림(`Hopyo_Side` · 층 가르기 없음 — 누가 하든 자기 개인 층 · 요구 855) → 바로 이 보기:
* · 머리 막대 = 엑셀 보기 몸(`Hopyo_Sheet` — 시트 · 호표 · 검산 · 연결)의 머리에 [끝내기]를 덧붙임
* · 아래 = 엑셀(탭 셋) + 오른쪽 호표 · 단가산출 목록(`Hopyo_Sheet` · `Hopyo_SheetSk`) · 만들기 = 호표 줄 [내 일위대가로](807-1)
* · [끝내기] = 물음(안 만든 단가산출 알림) → 서버가 이번 HP · 격자 저장본 지움 ·
* 만든 일위대가 · 단가산출(엑셀 사본 걷고 출처만) · 별칭 장부 남음 · 옛 「안 이은 줄」 띠는 785-7 에서 지움(판정 「고를 것」)
@@ -10,34 +10,19 @@
* 하루 지난 것은 서버가 지움 · 엑셀 자료는 보관하지 않음(필요하면 다시 올림)
* ========================================================================== */
import { createBadge } from "@ui/ui_template_badge";
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { createText } from "@ui/ui_template_text";
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
import { deleteHopyo, type HopyoFile, type HopyoScope } from "./M02_MasterTemplete_Hopyo_Api";
import type { HopyoSheetBody } from "./M02_MasterTemplete_Hopyo_Sheet";
import { accessFor, type ManageLayer } from "./M02_MasterTemplete_Layer";
import "./M02_MasterTemplete_Hopyo.css";
export const LAYERS: { layer: ManageLayer; text: string }[] = [
{ layer: "personal", text: "개인" },
{ layer: "company", text: "회사" },
{ layer: "system", text: "마스터" },
];
export const layerText = (layer: string): string =>
LAYERS.find((x) => x.layer === layer)?.text ?? layer;
/** 불러오기 자리 = 늘 자기 개인 층(요구 855 — 층 가르기 없음) */
export const MINE: HopyoScope = { layer: "personal", owner: null };
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
/** 쓸 수 있는 자기 층(개인 · 회사 · 마스터 차례) */
export async function writableLayers(): Promise<ManageLayer[]> {
const user = await fetchSessionUser().catch(() => null);
return LAYERS.map((x) => x.layer).filter((layer) => accessFor(layer, null, user).canWrite);
}
export interface HopyoImportArgs {
layer: ManageLayer;
file: HopyoFile;
/** [끝내기] 로 올린 파일을 지웠음 */
onEnded: () => void;
@@ -52,8 +37,8 @@ export interface HopyoImport {
}
export function buildHopyoImport(a: HopyoImportArgs): HopyoImport {
const { layer, file } = a;
const scope: HopyoScope = { layer, owner: null };
const { file } = a;
const scope = MINE;
let held = true;
let body: HopyoSheetBody | null = null;
@@ -74,7 +59,6 @@ export function buildHopyoImport(a: HopyoImportArgs): HopyoImport {
// ── 머리 막대에 덧붙일 것 ──
const finish = createButton({ label: "끝내기", variant: "filled", size: 28, onClick: () => void end() }); // prettier-ignore
finish.dataset.hopyoFinish = "";
const layerNote = createBadge(`${layerText(layer)} 층`, "neutral");
async function end(): Promise<void> {
finish.disabled = true;
@@ -111,7 +95,7 @@ export function buildHopyoImport(a: HopyoImportArgs): HopyoImport {
파일: file.파일,
target: scope,
canWrite: true,
headExtra: [layerNote, finish],
headExtra: [finish],
});
body = view;
root.replaceChildren(...view.parts);
@@ -3,8 +3,8 @@
* 좌측 「일위대가 불러오기」 컨테이너(PLAN 324-4 · 718-1 한시 · 785-4) —
* · [불러오기](구조물 조합 층 단추 꼴 `m02-layers__btn`) = 바로 파일 하나 고르기 창(엑셀 · 경쟁사 .estx · .STC · 814-1) → 올림 → 본 칸 불러오기 보기
* (`M02_MasterTemplete_Hopyo_Import` · 엑셀 + 오른쪽 호표 목록 · 가운데 상세 페이지 없음 · 누름 하나)
* · 결과 층 고름 = 작은 고름(쓸 수 있는 층 둘 이상일 때만 · 일반 = 개인 하나라 안 보임)
* · 「작업 중」 줄 = 층마다 서버 목록(같은 엑셀 작업 중 이어서) · 누르면 본 칸 불러오기 보기
* · 층 가르기 없음 — 누가 하든 올린 것 · 만든 것 = 자기 개인 층(요구 855 · 공유는 만든 뒤 본인이)
* · 「작업 중」 줄 = 자기 개인 층 서버 목록(같은 엑셀 작업 중 이어서) · 누르면 본 칸 불러오기 보기
* · 다른 엑셀 [불러오기] · 다른 화면으로 = 지금 보기 떠남(올린 파일 지움 · 743-1) — 고르고 안 만든 확인 캐시가 있으면 물음 한 번
* 옛 [별칭 장부](338-3)는 걷음(815-2) — 별칭은 M01 요소 「별칭」 칸 · M01 별칭 모아 보기.
* ========================================================================== */
@@ -12,22 +12,9 @@
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
import { createEmpty } from "@ui/ui_template_empty";
import { createListRow } from "@ui/ui_template_list_row";
import { createSegment } from "@ui/ui_template_segment";
import { t as L } from "@ui/ui_template_locale";
import {
listHopyo,
uploadHopyo,
type HopyoFile,
type HopyoScope,
} from "./M02_MasterTemplete_Hopyo_Api";
import {
buildHopyoImport,
LAYERS,
layerText,
writableLayers,
type HopyoImport,
} from "./M02_MasterTemplete_Hopyo_Import";
import type { ManageLayer } from "./M02_MasterTemplete_Layer";
import { listHopyo, uploadHopyo, type HopyoFile } from "./M02_MasterTemplete_Hopyo_Api";
import { buildHopyoImport, MINE, type HopyoImport } from "./M02_MasterTemplete_Hopyo_Import";
export interface HopyoSectionOptions {
/** 저장 안 한 고침을 버릴지 확인 — 취소면 false */
@@ -46,12 +33,9 @@ export interface HopyoSection {
}
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
const scopeOf = (layer: ManageLayer): HopyoScope => ({ layer, owner: null });
export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
const title = L("M02_HopyoImport");
let layers: ManageLayer[] = [];
let pick: ManageLayer = "personal";
const input = el("input", {
attrs: { type: "file", accept: ".xls,.xlsx,.estx,.stc", "data-hopyo-file": "" },
}) as HTMLInputElement;
@@ -65,50 +49,25 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
start.title =
"파일 하나 — 경쟁사 엑셀(.xls · .xlsx) 또는 경쟁사 파일(.estx · .STC) → 우리 정보로 다시 만듦(한시 · 끝내거나 떠나면 올린 파일 지움)";
start.dataset.hopyoWizardOpen = "";
const seg = el("div", { className: "m02-side__row" });
seg.hidden = true;
const jobs = el("div", { className: "m02-hopyo-side", attrs: { "data-hopyo-jobs": "" } });
const body = el("div", {
className: "m02-hopyo-side",
children: [el("div", { className: "m02-side__row", children: [start, input] }), seg, jobs],
children: [el("div", { className: "m02-side__row", children: [start, input] }), jobs],
});
let open: { key: string; view: HTMLElement; imp?: HopyoImport } | null = null;
/** 층 고름 · 작업 중 줄 — 쓸 수 있는 층을 처음 한 번 읽음 */
/** 작업 중 줄 — 자기 개인 층 서버 목록 */
async function refresh(): Promise<void> {
if (!layers.length) {
layers = await writableLayers();
if (!layers.includes(pick)) pick = layers[0] ?? "personal";
if (layers.length > 1) {
const s = createSegment({
options: LAYERS.filter((x) => layers.includes(x.layer)).map((x) => ({ value: x.layer, label: x.text })), // prettier-ignore
value: pick,
size: 24,
onChange: (v) => (pick = v as ManageLayer),
});
s.root.dataset.hopyoLayer = "";
seg.replaceChildren(s.root);
seg.hidden = false;
}
}
const rows = (
await Promise.all(
layers.map((layer) =>
listHopyo(scopeOf(layer))
.then((list) => list.map((f) => ({ layer, f })))
.catch(() => []),
),
)
).flat();
const rows = await listHopyo(MINE).catch(() => []);
jobs.replaceChildren(
...(rows.length
? rows.map(({ layer, f }) => {
? rows.map((f) => {
const row = createListRow({
label: f.파일,
sub: `호표 ${f.호표수} · ${layerText(layer)} — 이어서`,
sub: `호표 ${f.호표수} — 이어서`,
size: 28,
active: open?.imp !== undefined && open.key === f.키,
onClick: () => void show(layer, f),
onClick: () => void show(f),
});
row.dataset.hopyoKey = f.키;
return row;
@@ -119,8 +78,6 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
/** [불러오기] — 지금 보기를 떠날 수 있는지 묻고 바로 파일 고르기 창 */
async function choose(): Promise<void> {
if (!layers.length) await refresh().catch(() => {});
if (!layers.length) return void showToast("쓸 수 있는 층 없음", "warning");
if (!(await leaveOk())) return;
input.click();
}
@@ -129,14 +86,14 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
input.value = "";
if (!file) return;
start.disabled = true;
uploadHopyo(scopeOf(pick), file)
uploadHopyo(MINE, file)
.then((got: HopyoFile & { 이어서?: boolean; 원파일?: boolean }) => {
const tool = /\.estx$/i.test(file.name) ? "EST" : "STmate"; // 810-3 원파일 올림 안내
if (got.이어서) showToast(`같은 파일 작업 중 — ${got.키} 이어서`, "info");
else if (got.원파일) showToast(`${tool} 파일 — 단가산출표 · 산출근거`, "success");
else showToast(`올림 — 호표 ${got.호표수}`, got.어긋남 ? "warning" : "success");
if (got.읽기불가) showToast(`가져온 식으로 열 수 없음 — ${got.읽기불가}`, "warning");
return show(pick, got, true);
return show(got, true);
})
.catch((error) => showToast(why(error), "error"))
.finally(() => (start.disabled = false));
@@ -153,12 +110,11 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
}
/** 본 칸에 불러오기 보기 — 같은 파일이면 그대로 */
async function show(layer: ManageLayer, file: HopyoFile, checked = false): Promise<void> {
async function show(file: HopyoFile, checked = false): Promise<void> {
if (open?.imp && open.key === file.키 && open.view.isConnected) return;
if (!checked && !(await leaveOk())) return;
open?.imp?.leave(); // 불러오기 떠남 = 올린 파일 지움(743-1)
const imp = buildHopyoImport({
layer,
file,
onEnded: () => {
open = null;
+5 -5
View File
@@ -5,7 +5,7 @@
가져온 식(793-2 · `M02_Sangeun_Imported`) `…/sangeun/imported`(만들기 · 층 쓰기) · `/price` ·
`/link`(화면 캐시 문서 고침 · 저장 없음 · 층 읽기 · 기초로 바꾸기 없음 · 809-1) ·
불러오기 단산 블록 → 가져온 식(793-3b · `M02_Sangeun_FromHopyo`) `…/sangeun/from-hopyo`
(만들 층 쓰기 · 몸 `문서` = 미리 보기에서 고친 문서) · 미리 보기 `…/from-hopyo/preview`(층 읽기 · 안 씀 · 835-1).
(늘 개인 층 쓰기 · 몸 `문서` = 미리 보기에서 고친 문서) · 미리 보기 `…/from-hopyo/preview`(층 읽기 · 안 씀 · 835-1).
로직 사본(795-1 · `M02_Sangeun_Copy`) `…/sangeun/copy/price` [단가 갱신] · `/regen` [재생성]
(화면 캐시 문서에 할일 얹음 · 저장 없음 · 층 읽기).
수식 탭(796-2b · `M02_Sangeun_Equation`) `…/sangeun/equation`(층 읽기) · `/preview`(고침 미리 셈 ·
@@ -176,8 +176,8 @@ def sangeun_from_hopyo(
) -> dict:
"""불러오기 단산 블록 → 가져온 식 SK(793-3b) — 경로 층 = 만들 층 · 몸 층 = 파일 층 ·
답 = {이름, 검산, 단가기준, 다시씀}(같은 내용 = 그 SK)."""
folder = _root(session, layer, owner, write=True)
source = _root(session, body.층, body.주인)
folder = _root(session, "personal", None, write=True) # 불러오기 = 늘 개인 층(요구 855)
source = _root(session, "personal", None)
mine = _root(session, "personal", None, write=body.고름 is not None)
fp, name = _call(source, mat_pick.fingerprint, source, body.키) # 827-3 그 파일 재료 고름
if body.고름 is not None and fp:
@@ -200,8 +200,8 @@ def sangeun_from_hopyo_preview(
) -> dict:
"""[가져온 식으로 열기] 미리 보기(835-1 · 요구 852-1) — 층에 안 씀(재료 고름도 메모리) ·
답 = {문서, 검산, 같은것(같은 내용 SK 키 | None)} · [저장] = `from-hopyo` 몸 `문서`."""
folder = _root(session, layer, owner)
source = _root(session, body.층, body.주인)
folder = _root(session, "personal", None) # 불러오기 = 늘 개인 층(요구 855)
source = _root(session, "personal", None)
mine = _root(session, "personal", None)
fp, _ = _call(source, mat_pick.fingerprint, source, body.키)
picks = mat_pick.merge(mat_pick.read(mine, fp)["고름"], body.고름) if fp else {}
+3 -2
View File
@@ -352,10 +352,11 @@ def test_구조물_조합으로_규격_묶음_울진_돌기슭막이(client):
assert master_hash() == before
def test_구조물_조합으로_남의_층_403(client):
def test_구조물_조합으로_경로_층_무시_늘_개인(client): # 837-1 · 요구 855 — 옛 「남의 층 403」 갈음
key = _upload(client, "대흥")
body = {"층": "personal", "키": key, "번": 17}
assert client.post("/api/m02/layers/company/hopyo-item", json=body).status_code == 403
assert client.post("/api/m02/layers/company/hopyo-item", json=body).status_code == 200
assert list((layers_dir() / "labor").glob("*.json")) # 쓴 곳 = 개인 층(`layers_dir`)
def test_구조물_조합으로_같은_내용_409_씀_새로(client):
+10 -10
View File
@@ -27,20 +27,20 @@ def test_누름_하나_바로_엑셀():
choose = side.split("async function choose()")[1].split("input.addEventListener")[0]
assert "input.click();" in choose # [불러오기] = 바로 파일 고르기 창
change = side.split('input.addEventListener("change"')[1].split("async function leaveOk")[0]
assert (
"uploadHopyo(scopeOf(pick), file)" in change and "return show(pick, got, true);" in change
)
assert "uploadHopyo(MINE, file)" in change and "return show(got, true);" in change
show = side.split("async function show(")[1].split("async function showLedger")[0]
assert "buildHopyoImport({" in show and "opt.show(" in show # 올림 뒤 본 칸 바로
imp = _src("Import")
assert "m.buildHopyoSheet({" in imp and "headExtra: [layerNote, finish]" in imp
assert "m.buildHopyoSheet({" in imp and "headExtra: [finish]" in imp
def test_결과_층_고름_둘_이상일_때만():
side = _src("Side")
assert "layers.length > 1" in side and "seg.hidden = false;" in side
assert "seg.hidden = true;" in side # 처음 숨김 — 일반(개인 하나)은 안 보임
assert 'let pick: ManageLayer = "personal";' in side
def test_층_가르기_없음_늘_개인(): # 837-1 · 요구 855 — 예전 「결과 층 고름」 걷음
side, imp = _src("Side"), _src("Import")
for gone in ("createSegment", "writableLayers", "LAYERS", "layerText", "seg.hidden", "pick"):
assert gone not in side, gone
for gone in ("LAYERS", "layerText", "writableLayers", "layerNote", "createBadge"):
assert gone not in imp, gone
assert 'export const MINE: HopyoScope = { layer: "personal", owner: null };' in imp
def test_끝내기_머리_막대_끝냄_뒤_빈_본_칸():
@@ -71,5 +71,5 @@ def test_부품_상자만_700줄_늘리지_않음():
def test_해시_캐시_열쇠_없음():
# 809-1 — 판정 고름 캐시(`Hopyo_Picks`) 걷음 · 옮기기 = [내 일위대가로]
assert not (M02 / "logic" / "M02_MasterTemplete_Hopyo_Picks.ts").exists()
assert "draftKey" not in _src("Import") and "dirty: () => false" in _src("Import")
assert "draftKey" not in _src("Import") and "dirty: () => body?.matDirty()" in _src("Import")
assert "location.hash" not in _src("Import") + _src("Side") # 새 해시 열쇠 없음
@@ -0,0 +1,46 @@
"""837-1 일위대가 불러오기 = 늘 자기 개인 층(요구 855) — 서버 · 화면 없이 돎.
불러오기 길(`M02_Hopyo_Router` · `Sangeun_Router` from-hopyo)은 경로 층 · 주인 · 몸 층을 무시하고 개인 층만 씀."""
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from M02_MasterTemplete import M02_Hopyo_Router as hopyo # noqa: E402
def test_불러오기_길_층_고정(monkeypatch):
seen: list[tuple] = []
monkeypatch.setattr(
hopyo,
"_layer_root",
lambda s, layer, owner=None, write=False: seen.append((layer, owner, write)),
)
for layer, owner in (("company", "3"), ("system", None), ("personal", "3/9")):
hopyo._root({}, layer, owner, write=True)
hopyo._root({}, layer, owner)
assert seen == [("personal", None, True), ("personal", None, False)] * 3
def test_라우터_루트_모두_고정_함수():
src = (ROOT / "M02_MasterTemplete" / "M02_Hopyo_Router.py").read_text(encoding="utf-8")
assert "import _root as _layer_root" in src
assert "_layer_root(" in src.split("def _root(")[1].split("\n\n")[0]
assert src.count("_layer_root(") == 1 # 층 고르는 길은 이 한 곳뿐
def test_단산_블록_만들기_개인_층만():
src = (ROOT / "M02_MasterTemplete" / "M02_Sangeun_Router.py").read_text(encoding="utf-8")
for fn in ("def sangeun_from_hopyo(", "def sangeun_from_hopyo_preview("):
body = src.split(fn)[1].split("\n@router")[0]
assert "body.층" not in body and "_root(session, layer" not in body, fn
assert '_root(session, "personal", None' in body, fn
def test_화면_층_고름_없음():
m02 = ROOT / "M02_MasterTemplete"
side = (m02 / "M02_MasterTemplete_Hopyo_Side.ts").read_text(encoding="utf-8")
imp = (m02 / "M02_MasterTemplete_Hopyo_Import.ts").read_text(encoding="utf-8")
assert "createSegment" not in side and "writableLayers" not in side + imp
assert '"회사"' not in side + imp and '"마스터"' not in side + imp