feat(718-1): 일위대가 불러오기 = 한시 마법사 — 결과 층 고름 · [끝내기] 때 올린 파일 지움 · 같은 엑셀 겹침 막음

- 좌측 「일위대가 실무자료」 → 「일위대가 불러오기」(locale M02_HopyoImport) · 옛 파일 목록 걷고 [불러오기] → 본 칸 마법사(새 M02_MasterTemplete_Hopyo_Wizard.ts)
- 마법사 = ① 결과 층(쓸 수 있는 층만 · 기본 개인) · 결과 체크(일위대가 · 단가산출은 715-2 뒤) · 엑셀 고르기 · 작업 중 이어서 → ② 엑셀 보기 창(모두 고정으로 들여오기 · 구조물 조합으로 그대로) → ③ 끝내기
- 서버: 올림 지문(sha256) · 작업 중 같은 엑셀 = 이어서 · 끝낸 같은 엑셀 409 · 지우기 = 끝내기(HP · 격자 · 맞춤셈 캐시 지움 · 만든 일위대가 있으면 지문 기록 · 별칭 장부 남음) · HP 번 다시 안 씀 · 목록 = 지문 있는 것만(옛 HP · 붙여넣기 HP 파일 그대로 숨김)
- 시험: 새 지킴 test_718_1_hopyo_import(임시 storage) · 낡은 시험 고침 — 같은 엑셀 다시 올림이 새 키를 내던 가정(excel · grid · flow) · 붙여넣기 HP 목록 가정(705_2) · 좌측 판 글 대조(screen)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-01 15:08:17 +09:00
co-authored by Claude Opus 5.5
parent 9827926a20
commit f49b55a3d8
11 changed files with 421 additions and 148 deletions
+46 -8
View File
@@ -4,10 +4,14 @@
파일 = 읽은 결과만(원본 엑셀 안 둠) `{키, 파일, 올린때, 올린이, 시트, 호표, 검산}` · 키 `HP-######`
(층마다 다음 번호) · 맞춤 기억 = `{줄: {줄 열쇠: {로직?|자재?|빼기?, 입력값, 환산, 확정}}}`.
층 종류(`layers.KINDS`) 밖 폴더 — 양식 목록 · 복사 · 프로젝트 씨앗에 안 섞임 · 권한은 길 몫.
한시 불러오기(718-1) — 파일에 `지문`(올린 엑셀 sha256) · 목록 = 지문 있는 것(작업 중)만 · 옛 HP 는
파일 그대로 목록에서만 숨김 · 끝낸 기록 `실무자료/불러옴.json`
`{번, 지문 {지문: {파일, 키, 끝낸때, 만든}}}` · 번 = 낸 키 가장 큰 번(지운 뒤에도 키를 다시 안 씀).
"""
from __future__ import annotations
import hashlib
import json
import re
import threading
@@ -21,6 +25,7 @@ from M02_MasterTemplete import M02_MasterTemplete_Store as store
DIRNAME = "실무자료"
MEMORY = "맞춤"
GRID = "격자"
DONE = "불러옴"
NO_GRID = "격자 없음 — 다시 올리면 보임"
_KEY = re.compile(r"^HP-(\d{6})$")
# ponytail: 프로세스 안 잠금 — 서버를 여럿 띄우면 파일 잠금으로
@@ -58,16 +63,33 @@ def brief(doc: dict[str, Any]) -> dict[str, Any]:
}
def fingerprint(data: bytes) -> str:
"""올린 엑셀 지문 — 같은 파일 다시 올림을 알아봄."""
return hashlib.sha256(data).hexdigest()
def rows(folder: str | Path) -> list[dict[str, Any]]:
"""층 실무자료 목록(키 차례)."""
"""층 실무자료 목록(키 차례) — 작업 중(지문 있음)만 · 옛 HP 는 숨김."""
out = []
for path in sorted(_dir(folder).glob("HP-*.json")):
doc = _load(path)
if isinstance(doc, dict) and _KEY.match(path.stem):
out.append(brief(doc))
if isinstance(doc, dict) and _KEY.match(path.stem) and doc.get("지문"):
out.append({**brief(doc), "지문": doc["지문"]})
return out
def done(folder: str | Path) -> dict[str, Any]:
"""끝낸 기록 `{번, 지문 {지문: {파일, 키, 끝낸때, 만든}}}`."""
doc = _load(_dir(folder) / f"{DONE}.json")
doc = doc if isinstance(doc, dict) else {}
marks = doc.get("지문")
return {"번": int(doc.get("번") or 0), "지문": marks if isinstance(marks, dict) else {}}
def _put_done(folder: str | Path, got: dict[str, Any]) -> None:
atomic_write_json(_dir(folder) / f"{DONE}.json", got)
def read(folder: str | Path, key: str) -> dict[str, Any]:
doc = _load(_path(folder, key))
if not isinstance(doc, dict):
@@ -81,25 +103,30 @@ def save(
parsed: dict[str, Any],
who: Any,
grid: dict[str, Any] | None = None,
mark: str | None = None,
) -> dict[str, Any]:
"""읽은 결과(+ 격자) → 새 키로 씀 → 목록 한 줄."""
"""읽은 결과(+ 격자) → 새 키로 씀 → 목록 한 줄 · `mark` = 올린 엑셀 지문."""
with _LOCK:
have = [
int(m.group(1)) for p in _dir(folder).glob("HP-*.json") if (m := _KEY.match(p.stem))
]
key = f"HP-{max(have, default=0) + 1:06d}"
log = done(folder)
log["번"] = max(*have, log["번"], 0) + 1
key = f"HP-{log['번']:06d}"
doc = {
"키": key,
"파일": name,
"올린때": utc_now_text(),
"올린이": who,
**({"지문": mark} if mark else {}),
**parsed,
}
if grid is not None:
book = {**grid["책"], "열": key}
atomic_write_json(_grid_path(folder, key), {**grid, "책": book})
atomic_write_json(_path(folder, key), doc)
return brief(doc)
_put_done(folder, log)
return {**brief(doc), **({"지문": mark} if mark else {})}
def grid(folder: str | Path, key: str) -> dict[str, Any]:
@@ -110,11 +137,22 @@ def grid(folder: str | Path, key: str) -> dict[str, Any]:
return doc
def delete(folder: str | Path, key: str) -> None:
def delete(folder: str | Path, key: str, made: list[str] | None = None) -> None:
"""HP · 격자 지움 · 만든 것(`made` SC 키)이 있으면 지문을 끝낸 기록에 남김(다시 올림 막음)."""
path = _path(folder, key)
with _LOCK:
if not path.is_file():
doc = _load(path)
if not isinstance(doc, dict):
raise store.StoreError(404, f"없는 실무자료 「{key}」")
if made and doc.get("지문"):
log = done(folder)
log["지문"][doc["지문"]] = {
"파일": doc.get("파일"),
"키": key,
"끝낸때": utc_now_text(),
"만든": made,
}
_put_done(folder, log)
path.unlink()
_grid_path(folder, key).unlink(missing_ok=True)
+35 -5
View File
@@ -6,7 +6,9 @@
사전조합 나무 자유 폴더(158-1 · `M02_Precombo_Folders` · 읽기 = 층 읽기 · 고침 = 층 쓰기) +
일위대가 실무자료(324 · `M02_Hopyo_*` · 올리기 · 지우기 = 층 쓰기 · 목록 · 읽기 = 층 읽기) ·
338 = 확인한 줄만 만들기(몸 없음 400 · 옛 즉시 확정 길 없앰) · 별칭 장부 보기 · 고치기 ·
올린 뒤 맞춤 뒤 셈(`M02_Hopyo_Fit.ensure`) · 시동 때 우리 지문 데움(`M02_Hopyo_Ours.warm`).
올린 뒤 맞춤 뒤 셈(`M02_Hopyo_Fit.ensure`) · 시동 때 우리 지문 데움(`M02_Hopyo_Ours.warm`) ·
718-1 한시 불러오기 = 올림 지문(끝낸 같은 엑셀 409 · 작업 중 같은 엑셀 = 그 파일 `이어서`) ·
지우기 = [끝내기](HP · 격자 · 맞춤셈 캐시 지움 · 만든 일위대가가 있으면 지문 기록 · 별칭 장부 남음).
층 권한 = `Router_Items._root`(`M02_Layer_Access` 한 벌).
⚠ 등록은 `main.py`(로그인만).
"""
@@ -198,6 +200,16 @@ def _who(session: Session) -> dict[str, Any]:
def _upload(name: str, data: bytes, who: dict[str, Any]) -> dict[str, Any]:
mark = hopyo_store.fingerprint(data)
if old := hopyo_store.done(store.root())["지문"].get(mark):
made = len(old.get("만든") or [])
when = str(old.get("끝낸때") or "")[:10]
raise store.StoreError(
409,
f"이미 불러온 엑셀 「{old.get('파일')}」({when} · 일위대가 {made}) — 다시 만들지 않음",
)
if same := next((r for r in hopyo_store.rows(store.root()) if r.get("지문") == mark), None):
return {**same, "이어서": True}
try:
parsed = hopyo_excel.read_book(name, data)
except hopyo_excel.ExcelError as error:
@@ -206,7 +218,7 @@ def _upload(name: str, data: bytes, who: dict[str, Any]) -> dict[str, Any]:
grid = hopyo_grid.read(name, data, parsed)
except Exception: # 격자는 보기용 덤 — 못 만들어도 올리기는 됨(보기 창 404)
grid = None
row = hopyo_store.save(store.root(), name, parsed, who, grid)
row = hopyo_store.save(store.root(), name, parsed, who, grid, mark)
if (ensure := _later("M02_Hopyo_Fit", "ensure")) is not None:
ensure(store.root(), row["키"]) # 맞춤 뒤 셈(일감 줄 · 화면 안 기다림)
return row
@@ -441,10 +453,28 @@ def hopyo_ledger_write(
def hopyo_delete(
layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session)
) -> dict:
"""실무자료 지우기(만든 사전조합 · 맞춤 기억은 그대로)."""
"""실무자료 지우기 = 불러오기 [끝내기](718-1) — HP · 격자 · 맞춤셈 캐시 지움 · 만든 일위대가
(이 층 확인된 본뜬 「키 제N호표」)는 그대로 · 있으면 지문 기록(같은 엑셀 다시 올림 막음) ·
별칭 장부 그대로 → `{지움, 만든}`."""
folder = _root(session, layer, owner, write=True)
_call(folder, hopyo_store.delete, folder, key)
return {"지움": key}
return _call(folder, _finish, folder, key)
def _finish(folder: Path, key: str) -> dict[str, Any]:
head = f"{key} 제"
made = sorted(
{sc for mark, sc in hopyo_build.confirmed(folder).items() if mark.startswith(head)}
)
if (cache_path := _later("M02_Hopyo_Fit", "cache_path")) is not None:
try:
got = cache_path(folder, key)
except (OSError, store.StoreError):
got = None
# ponytail: 뒤 셈이 도는 중이면 끝낸 뒤 캐시를 다시 쓸 수 있음 — tmp 캐시라 둠
for old in got.parent.glob(f"{'_'.join(got.name.split('_')[:2])}_*.json") if got else []:
old.unlink(missing_ok=True)
hopyo_store.delete(folder, key, made)
return {"지움": key, "만든": len(made)}
class PasteBody(_Strict):
@@ -1,21 +1,19 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_Side.ts
* 좌측 「일위대가 실무자료」 컨테이너(PLAN 324-4 · 359-1 · 일위대가 사전조합 아래) — 층 = 구조물 조합 층(화면 층) ·
* [파일 업로드](구조물 조합 층 단추 꼴 `m02-layers__btn` · 숨은 file 입력 .xls · .xlsx · 쓰기 권한 없으면 꺼짐) +
* 파일 줄 → 누르면 바로 엑셀 보기 창(329 · `M02_MasterTemplete_Hopyo_Sheet` 늦게 불러옴 · 지우기 · 검산 · 셈 중은 창 머리) ·
* 고름 표시 = 그 파일 창이 열린 동안 · 올린 직후도 엑셀 보기 창 ·
* 파일 목록 아래 [별칭 장부](338-3) → 본 칸 장부 화면(`M02_MasterTemplete_Hopyo_Ledger`).
* 좌측 「일위대가 불러오기」 컨테이너(PLAN 324-4 · 718-1 한시) — [불러오기](구조물 조합 층 단추 꼴 `m02-layers__btn`) →
* 본 칸 마법사(`M02_MasterTemplete_Hopyo_Wizard` · 결과 층 고름 · 작업 중 이어서 · [끝내기] 때 올린 파일 지움) ·
* 옛 파일 목록 없음(옛 HP 는 서버 목록에서 숨김) ·
* 아래 [별칭 장부](338-3) → 본 칸 장부 화면(`M02_MasterTemplete_Hopyo_Ledger` · 층 = 구조물 조합 층).
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { listHopyo, pageScope, uploadHopyo, type HopyoFile } from "./M02_MasterTemplete_Hopyo_Api";
import { createButton, el } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { pageScope } from "./M02_MasterTemplete_Hopyo_Api";
import { buildHopyoLedger, LEDGER_HINT, LEDGER_TITLE } from "./M02_MasterTemplete_Hopyo_Ledger";
import { createEmpty } from "@ui/ui_template_empty";
export const HOPYO_TITLE = "일위대가 실무자료";
import { buildHopyoWizard } from "./M02_MasterTemplete_Hopyo_Wizard";
export interface HopyoSectionOptions {
/** 이 층 쓰기 권한 — 없으면 올리기 꺼짐 · 엑셀 보기 창 보기만 */
/** 이 층 쓰기 권한 — 별칭 장부 고침 */
canWrite: boolean;
/** 저장 안 한 고침을 버릴지 확인 — 취소면 false */
confirmLeave: () => Promise<boolean>;
@@ -30,122 +28,48 @@ export interface HopyoSection {
body: HTMLElement;
/** 다른 화면이 열렸으면 고름 표시를 지움 */
paint: () => void;
refresh: () => Promise<void>;
}
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
const WIZARD = "불러오기";
export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
const title = L("M02_HopyoImport");
const scope = pageScope();
const list = el("div", { className: "m02-side__items ui-sidebar-list" });
const input = el("input", {
attrs: { type: "file", accept: ".xls,.xlsx", "data-hopyo-file": "" },
}) as HTMLInputElement;
input.hidden = true;
// 구조물 조합 층 단추(`TreePanel_Roots` [마스터])와 같은 꼴 — 보통 크기 · 같은 클래스
const upload = createButton({
label: "파일 업로드",
const start = createButton({
label: "불러오기",
variant: "filled",
disabled: !opt.canWrite,
onClick: () => input.click(),
onClick: () => void showView(WIZARD),
});
upload.classList.add("m02-layers__btn");
upload.title = opt.canWrite ? "설계내역서 엑셀(일위대가 시트)" : "이 층 쓰기 권한 없음";
start.classList.add("m02-layers__btn");
start.title = "다른 프로그램 일위대가 엑셀 → 우리 일위대가(한시 · 끝내면 올린 파일 지움)";
start.dataset.hopyoWizardOpen = "";
const ledger = el("button", {
className: "m02-side__item ui-sidebar-row",
text: LEDGER_TITLE,
attrs: { type: "button", title: LEDGER_HINT, "data-hopyo-ledger-open": "" },
});
ledger.addEventListener("click", () => void showLedger());
ledger.addEventListener("click", () => void showView(LEDGER_TITLE));
const body = el("div", {
className: "m02-hopyo-side",
children: [el("div", { className: "m02-side__row", children: [upload, input] }), list, ledger],
children: [el("div", { className: "m02-side__row", children: [start] }), ledger],
});
let files: HopyoFile[] = [];
let open: { key: string; view: HTMLElement } | null = null;
let sheetKey: string | null = null; // 엑셀 보기 창이 열린 파일
let seq = 0;
input.addEventListener("change", () => {
const file = input.files?.[0];
input.value = "";
if (!file) return;
upload.disabled = true;
uploadHopyo(scope, file)
.then(async (got) => {
const wrong = got.어긋남 ? ` · 검산 어긋남 ${got.어긋남}` : "";
showToast(`올림 — 호표 ${got.호표수}${wrong}`, got.어긋남 ? "warning" : "success");
await refresh();
await showSheet(got);
})
.catch((error) => showToast(why(error), "error"))
.finally(() => (upload.disabled = !opt.canWrite));
});
function paintList(): void {
const paintList = (): void => {
ledger.classList.toggle("is-active", open?.key === LEDGER_TITLE);
list.replaceChildren(
...(files.length
? files.map((f) => {
const row = el("button", {
className: `m02-side__item ui-sidebar-row${sheetKey === f.키 ? " is-active" : ""}`,
text: `${f.파일} · 호표 ${f.호표수}`,
attrs: { type: "button", title: `${f.키} · ${f.올린때}`, "data-hopyo-key": f.키 },
});
row.addEventListener("click", () => void showSheet(f));
return row;
})
: [createEmpty({ text: "올린 실무자료 없음", small: true })]),
);
}
};
/** 파일 줄 누름 · 올린 직후 = 바로 엑셀 보기 창(본 칸은 그대로 · 열 때 불러옴) */
async function showSheet(file: HopyoFile): Promise<void> {
const sheet = await import("./M02_MasterTemplete_Hopyo_Sheet").catch((error: unknown) => {
showToast(why(error), "error");
return null;
});
if (!sheet) return;
sheetKey = file.키;
paintList();
sheet.openHopyoSheet({
scope,
key: file.키,
파일: file.파일,
// 사전조합은 어느 화면 층에서든 로그인한 사람 개인 층에 만듦 — 회사 · 마스터는 구조물 카드 [가져오기]
target: { layer: "personal", owner: null },
canWrite: true,
onClose: () => {
if (sheetKey === file.키) sheetKey = null;
paintList();
},
// [삭제]는 실무자료 파일이 있는 화면 층 쓰기 권한 때만
onDeleted: opt.canWrite ? () => void refresh() : undefined,
});
}
async function showLedger(): Promise<void> {
if (open?.key === LEDGER_TITLE && open.view.isConnected) return;
async function showView(key: string): Promise<void> {
if (open?.key === key && open.view.isConnected) return;
if (!(await opt.confirmLeave())) return;
const view = buildHopyoLedger({ scope, canWrite: opt.canWrite });
opt.show(`${HOPYO_TITLE} — ${LEDGER_TITLE}`, view);
const view =
key === WIZARD ? buildHopyoWizard() : buildHopyoLedger({ scope, canWrite: opt.canWrite });
opt.show(key === WIZARD ? title : `${title} — ${LEDGER_TITLE}`, view);
opt.onOpen();
open = { key: LEDGER_TITLE, view };
open = { key, view };
paintList();
}
async function refresh(): Promise<void> {
const mine = ++seq;
try {
const got = await listHopyo(scope);
if (mine === seq) files = got;
} catch (error) {
if (mine === seq) files = [];
showToast(why(error), "error");
}
if (mine === seq) paintList();
}
const paint = (): void => {
if (open && !open.view.isConnected) {
open = null;
@@ -153,6 +77,5 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
}
};
void refresh();
return { title: HOPYO_TITLE, body, paint, refresh };
return { title, body, paint };
}
@@ -0,0 +1,206 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_Wizard.ts
* 일위대가 불러오기 = 한시 마법사(PLAN 718-1 · 요구서 내역12 4장 22) — 본 칸 한 화면 · 걸음 셋:
* ① 결과 층(쓸 수 있는 층만 · 기본 개인) · 결과(일위대가 · 단가산출 = 715-2 뒤 켬) · 엑셀 고르기 → 올림
* (그 층에 둠 · 같은 엑셀 작업 중 = 그 파일 이어서 · 끝낸 같은 엑셀 = 서버 409 안내) · 작업 중 파일 이어서
* ② 보기 · 확인 · 만들기 = 엑셀 보기 창(`Hopyo_Sheet` — [모두 고정으로 들여오기] · 호표 [수정] →
* 확인 창 만들기 · [구조물 조합으로] 그대로) · 만들 층 = 고른 층
* ③ [끝내기] = 물음 → 서버가 이번 HP · 격자 · 맞춤셈 캐시 지움 · 만든 일위대가 · 별칭 장부 남음 · 지문 기록
* 작업 중 = 서버에 보관(떠났다 와도 ① 목록에서 이어서) · 옛 HP(지문 없음)는 서버 목록에서 숨김.
* ========================================================================== */
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
import { createCheckbox } from "@ui/ui_template_checkbox";
import { createEmpty } from "@ui/ui_template_empty";
import { createPanelHead } from "@ui/ui_template_panel_head";
import { createSegment } from "@ui/ui_template_segment";
import { createText } from "@ui/ui_template_text";
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
import {
deleteHopyo,
listHopyo,
uploadHopyo,
type HopyoFile,
type HopyoScope,
} from "./M02_MasterTemplete_Hopyo_Api";
import { accessFor, type ManageLayer } from "./M02_MasterTemplete_Layer";
const LAYERS: { layer: ManageLayer; text: string }[] = [
{ layer: "personal", text: "개인" },
{ layer: "company", text: "회사" },
{ layer: "system", text: "마스터" },
];
const STEPS = [
{ value: "1", label: "① 층 · 엑셀" },
{ value: "2", label: "② 보기 · 확인 · 만들기" },
{ value: "3", label: "③ 끝내기" },
];
/** 단가산출 결과 — M02 단가산출 서고(715-2)가 들어오면 켬 */
export const SANGEUN_READY = false;
const SANGEUN_WHY = "M02 단가산출 칸(715-2)이 들어온 뒤 켬";
type Got = HopyoFile & { 이어서?: boolean };
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
const textOf = (layer: string): string => LAYERS.find((x) => x.layer === layer)?.text ?? layer;
/** 쓸 수 있는 자기 층(개인 · 회사 · 마스터 차례) */
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 function buildHopyoWizard(): HTMLElement {
const steps = createSegment({
options: STEPS.map((s) => ({ ...s, disabled: true })),
value: "1",
});
steps.root.dataset.hopyoSteps = "";
const body = el("div", { className: "m02-hopyo" });
const root = el("div", {
className: "m02-hopyo",
attrs: { "data-hopyo-wizard": "" },
children: [
createText(
"한시 — 다른 프로그램 일위대가 엑셀을 우리 일위대가로 옮김 · 끝내면 올린 파일 지움 · 별칭 장부는 남음",
"hint",
),
el("div", { className: "ui-toolbar", children: [steps.root] }),
body,
],
});
let layers: ManageLayer[] = [];
let pick: ManageLayer = "personal";
const scopeOf = (layer: ManageLayer): HopyoScope => ({ layer, owner: null });
async function first(): Promise<void> {
steps.set("1");
layers = await writableLayers();
if (!layers.includes(pick)) pick = layers[0] ?? "personal";
const input = el("input", {
attrs: { type: "file", accept: ".xls,.xlsx", "data-hopyo-file": "" },
}) as HTMLInputElement;
input.hidden = true;
const upload = createButton({
label: "엑셀 고르기",
variant: "filled",
disabled: !layers.length,
onClick: () => input.click(),
});
upload.title = layers.length ? "설계내역서 엑셀(일위대가 시트)" : "쓸 수 있는 층 없음";
input.addEventListener("change", () => {
const file = input.files?.[0];
input.value = "";
if (!file) return;
upload.disabled = true;
uploadHopyo(scopeOf(pick), file)
.then((got: Got) => {
if (got.이어서) showToast(`같은 엑셀 작업 중 — ${got.키} 이어서`, "info");
else showToast(`올림 — 호표 ${got.호표수}`, got.어긋남 ? "warning" : "success");
second(pick, got);
})
.catch((error) => showToast(why(error), "error"))
.finally(() => (upload.disabled = !layers.length));
});
const seg = createSegment({
options: LAYERS.map((x) => ({
value: x.layer,
label: x.text,
disabled: !layers.includes(x.layer),
})),
value: pick,
onChange: (v) => (pick = v as ManageLayer),
});
seg.root.dataset.hopyoLayer = "";
const ilwi = createCheckbox("일위대가(고정 줄)", { checked: true, disabled: true });
const sangeun = createCheckbox("단가산출(산근 블록 · 바탕 없음)", { disabled: !SANGEUN_READY });
sangeun.root.title = SANGEUN_READY ? "" : SANGEUN_WHY;
const busy = el("div", { className: "m02-hopyo" });
body.replaceChildren(
createText("결과 층", "muted", 14),
el("div", { className: "ui-toolbar", children: [seg.root] }),
createText("결과", "muted", 14),
el("div", { className: "ui-toolbar", children: [ilwi.root, sangeun.root] }),
...(SANGEUN_READY ? [] : [createText(`단가산출 — ${SANGEUN_WHY}`, "hint")]),
el("div", { className: "ui-toolbar", children: [upload, input] }),
createPanelHead({ title: "작업 중", level: 4, divider: true }).root,
busy,
);
const rows = (
await Promise.all(
layers.map((layer) =>
listHopyo(scopeOf(layer))
.then((list) => list.map((f) => ({ layer, f })))
.catch(() => []),
),
)
).flat();
busy.replaceChildren(
...(rows.length
? rows.map(({ layer, f }) => {
const row = createButton({
label: `${f.파일} · 호표 ${f.호표수} · ${textOf(layer)} — 이어서`,
variant: "ghost",
onClick: () => second(layer, f),
});
row.dataset.hopyoKey = f.키;
return row;
})
: [createEmpty({ text: "작업 중인 불러오기 없음", small: true })]),
);
}
function second(layer: ManageLayer, file: HopyoFile): void {
steps.set("2");
const scope = scopeOf(layer);
const view = createButton({
label: "엑셀 보기 · 확인 · 만들기",
variant: "filled",
onClick: () =>
void import("./M02_MasterTemplete_Hopyo_Sheet")
.then((m) =>
m.openHopyoSheet({
scope,
key: file.키,
파일: file.파일,
target: scope,
canWrite: true,
onDeleted: () => void first(),
}),
)
.catch((error: unknown) => showToast(why(error), "error")),
});
const finish = createButton({ label: "끝내기", variant: "ghost", onClick: () => void end() });
finish.dataset.hopyoFinish = "";
const back = createButton({ label: "처음으로", variant: "link", onClick: () => void first() });
back.title = "작업은 그대로 보관 — 처음 화면 「작업 중」에서 이어서";
const wrong = file.어긋남 ? ` · 검산 어긋남 ${file.어긋남}` : "";
body.replaceChildren(
createText(`${file.파일} · ${textOf(layer)} 층 · 호표 ${file.호표수}${wrong}`, "muted", 14),
createText(
"엑셀 보기 창에서 [모두 고정으로 들여오기] 또는 호표 [수정] → 확인하고 만들기 · [구조물 조합으로] · 만든 일위대가는 고른 층에 들어감",
"hint",
),
el("div", { className: "ui-toolbar", children: [view, finish, back] }),
);
async function end(): Promise<void> {
steps.set("3");
const ok = await showConfirmDialog(
`「${file.파일}」 불러오기를 끝냄 — 올린 파일(엑셀 결과 · 격자 · 맞춤 캐시)을 지움 · 만든 일위대가와 별칭 장부는 남음 · 일위대가를 만들었으면 같은 엑셀은 다시 올리지 못함`,
"끝내기",
);
if (!ok) return steps.set("2");
try {
const got = (await deleteHopyo(scope, file.키)) as { 만든?: number };
showToast(`끝냄 — 만든 일위대가 ${got.만든 ?? 0} · 올린 파일 지움`, "success");
} catch (error) {
showToast(why(error), "error");
return steps.set("2");
}
await first();
}
}
void first();
return root;
}
+4 -2
View File
@@ -94,8 +94,10 @@ def test_실무_xlsx_넷_붙여넣기_만듦_합계_같음(client, tag):
assert got["같음"] == FILES[tag][1] and all(abs(x["차이"]) <= 1 for x in got["다름"])
assert len(got["만듦"]) == got["호표"] and got["고정"] + got["호표참조"] == got["줄"]
assert master_hash() == before
rows = client.get("/api/m02/layers/personal/hopyo").json()["줄"]
assert rows[0]["파일"].startswith("붙여넣기") and rows[0]["시트"] == "붙여넣기"
# 718-1 — 붙여넣기 실무자료 = 지문 없음 → 불러오기 목록엔 숨김 · 파일은 남음
assert client.get("/api/m02/layers/personal/hopyo").json()["줄"] == []
doc = client.get(f"/api/m02/layers/personal/hopyo/{got['키']}").json()
assert doc["파일"].startswith("붙여넣기") and doc["시트"] == "붙여넣기"
def _block(rows, no):
@@ -0,0 +1,67 @@
"""일위대가 불러오기 한시(PLAN 718-1) — 올림 지문 · [끝내기] 지움 · 겹침 막음.
같은 엑셀 작업 중 다시 올림 = 그 파일 이어서(새 키 없음) · [끝내기] = HP · 격자 · 맞춤셈 캐시 0 ·
만든 일위대가 · 별칭 장부 그대로 · 끝낸 같은 엑셀 다시 올림 409 · 만든 것 없이 지움 = 다시 올림 됨 ·
옛 HP(지문 없음) = 파일 그대로 목록에서만 숨김 · 키 번 = 지운 뒤에도 다시 안 씀. 층 = 임시 폴더.
"""
import json
from M02_MasterTemplete import M02_Hopyo_Fit as fit
from M02_MasterTemplete import M02_Hopyo_Store as hs
from M02_MasterTemplete import M02_Template_Layers as layers
from resources.tester.test_m02_hopyo_excel import NORTH, URL, _post, client # noqa: F401
ALL = "/api/m02/layers/personal/hopyo-build-all"
def _keys(client):
return [r["키"] for r in client.get(URL.format("personal")).json()["줄"]]
def test_이어서_끝내기_지움_겹침_막음(client):
folder = layers.personal_dir(7, 42)
first = _post(client).json()
assert first["키"] == "HP-000001" and len(first["지문"]) == 64
again = _post(client).json()
assert again["키"] == "HP-000001" and again["이어서"] is True # 작업 중 같은 엑셀 = 새 키 없음
assert _keys(client) == ["HP-000001"]
made = client.post(ALL, json={"층": "personal", "키": "HP-000001"}).json()
assert len(made["만듦"]) == 68
hs.remember(folder, {"장부 줄": {"갈래": "안 씀"}}) # 별칭 장부 = 끝내기 뒤 남음
cache = fit.cache_path(folder, "HP-000001")
cache.write_text("{}", encoding="utf-8")
old_cache = cache.with_name("_".join(cache.name.split("_")[:2]) + "_옛판_1.json")
old_cache.write_text("{}", encoding="utf-8")
sc_before = sorted((folder / "precombo").glob("SC-*.json"))
res = client.delete(URL.format("personal") + "/HP-000001")
assert res.status_code == 200 and res.json() == {"지움": "HP-000001", "만든": 68}
assert not list((folder / hs.DIRNAME).glob("HP-*.json")) # 이번 HP 0
assert not list((folder / hs.DIRNAME / hs.GRID).glob("HP-*.json"))
assert not cache.exists() and not old_cache.exists()
assert hs.memory(folder) == {"장부 줄": {"갈래": "안 씀"}}
assert sorted((folder / "precombo").glob("SC-*.json")) == sc_before # 만든 일위대가 그대로
block = _post(client) # 끝낸 같은 엑셀 = 막음 · 안내
assert block.status_code == 409 and "이미 불러온 엑셀" in block.json()["detail"]
assert "일위대가 68" in block.json()["detail"] and _keys(client) == []
assert sorted((folder / "precombo").glob("SC-*.json")) == sc_before
def test_만든_것_없이_지움_다시_올림_키_번_안_되씀(client):
assert _post(client).json()["키"] == "HP-000001"
assert client.delete(URL.format("personal") + "/HP-000001").json()["만든"] == 0
got = _post(client) # 만든 것 없음 = 막지 않음 · 번은 이어서
assert got.status_code == 200 and got.json()["키"] == "HP-000002"
def test_옛_HP_목록에서만_숨김(client):
folder = layers.personal_dir(7, 42) / hs.DIRNAME
folder.mkdir(parents=True)
old = folder / "HP-000007.json"
old.write_text(json.dumps({"키": "HP-000007", "파일": "옛.xls", "호표": []}), encoding="utf-8")
assert _keys(client) == []
assert client.get("/api/m02/layers/personal/hopyo-picks").json()["줄"] == []
assert _post(client).json()["키"] == "HP-000008" # 옛 번 뒤로
assert old.is_file() and _keys(client) == ["HP-000008"]
+3 -1
View File
@@ -179,8 +179,10 @@ def test_올리기_목록_읽기_지우기(client):
assert (folder / hs.DIRNAME / "HP-000001.json").is_file()
assert all(r["종류"] in layers.KINDS for r in layers.list_templates(folder)) # 양식에 안 섞임
assert not any("실무자료" in str(p) for _, p in layers.layer_files("precombo"))
assert _post(client).json()["키"] == "HP-000002"
again = _post(client).json() # 718-1 — 작업 중 같은 엑셀 = 그 파일 이어서
assert again["키"] == "HP-000001" and again["이어서"] is True
assert client.delete(URL.format("personal") + "/HP-000001").status_code == 200
assert _post(client).json()["키"] == "HP-000002" # 지운 번 다시 안 씀
assert [r["키"] for r in client.get(URL.format("personal")).json()["줄"]] == ["HP-000002"]
assert client.get(URL.format("personal") + "/HP-000001").status_code == 404
assert client.get(URL.format("personal") + "/../x").status_code == 404
+5 -3
View File
@@ -105,8 +105,8 @@ def test_끝까지_확인한_줄만_별칭(client):
keys = {r["열쇠"]: r for r in rows}
assert {p["로직"] for p in keys[rebar["맞춤"]["열쇠"]]["짝"]} == {"GC000206", "GC000207"}
# 다시 올림 = 확인한 줄은 별칭 · 체크 · 만든 것이 보임
_up(client, "HP-000002")
again = {x["이름"]: x["맞춤"] for x in _one(client, "HP-000002", no)["줄"]}
assert _post(client).json()["이어서"] is True # 718-1 — 같은 엑셀 = 그 파일 이어서(새 키 없음)
again = {x["이름"]: x["맞춤"] for x in _one(client, "HP-000001", no)["줄"]}
assert again["레미콘타설(장비사용)"]["상태"] == "별칭" and again["레미콘타설(장비사용)"]["체크"]
assert again["철근가공조립(현장)"]["상태"] == "별칭"
assert again["철근가공조립(현장)"]["대상"][0]["출처"] == {
@@ -138,7 +138,9 @@ def test_막는_길(client):
"/api/m02/layers/personal/hopyo-build",
json={"층": "personal", "키": "HP-000001", "번": 1, "줄": []},
)
assert empty.status_code == 200 and empty.json()["줄수"]["품셈"] == 0 # 빈 몸 = 모두 고정(705-3)
assert (
empty.status_code == 200 and empty.json()["줄수"]["품셈"] == 0
) # 빈 몸 = 모두 고정(705-3)
money = client.post(
"/api/m02/layers/personal/hopyo-build",
json={"층": "personal", "키": "HP-000001", "번": 1, "줄": [{"차례": 1, "금액": 1}]},
+4 -2
View File
@@ -84,13 +84,15 @@ def test_올리기_격자_200_404_지우기(client):
path.unlink() # 옛 파일(324) = 격자 없음
old = client.get(GRID.format("personal", "HP-000001"))
assert old.status_code == 404 and old.json()["detail"] == "격자 없음 — 다시 올리면 보임"
assert _post(client).status_code == 200
assert client.delete(URL.format("personal") + "/HP-000001").status_code == 200
assert _post(client).status_code == 200 # 718-1 — 같은 엑셀은 지운 뒤 새 번
assert client.get(GRID.format("personal", "HP-000002")).status_code == 200
assert client.delete(URL.format("personal") + "/HP-000002").status_code == 200
assert not (folder / hs.DIRNAME / hs.GRID / "HP-000002.json").exists()
assert client.get(GRID.format("personal", "HP-000002")).status_code == 404
assert client.get(GRID.format("personal", "../x")).status_code == 404
assert client.get(URL.format("personal") + "/HP-000001/3").status_code == 200 # 길 안 부딪힘
gone = client.get(URL.format("personal") + "/HP-000001/3") # 길 안 부딪힘(718-1 지운 뒤 = 없음)
assert gone.status_code == 404 and "없는 실무자료" in gone.json()["detail"]
assert master_hash() == before
+22 -22
View File
@@ -29,6 +29,7 @@ NEW = (
"Hopyo_CheckRows",
"Hopyo_Choose",
"Hopyo_Ledger",
"Hopyo_Wizard",
)
ROW = {
"층": "system",
@@ -301,7 +302,7 @@ def test_옛_즉시_확정과_바로_만들기_없음():
"근거 맞음 모두 확정",
'"확정"',
'"사전조합 만들기"',
"불러오기",
'"불러오기" 옛 단추', # 718-1 — 「불러오기」 = 칸 이름 · 마법사 단추라 낱말은 뺌
):
assert label not in every, label
users = sorted(name for name, text in texts.items() if "buildHopyo(" in text)
@@ -342,20 +343,18 @@ def test_좌측_판_자리와_권한():
assert "canWrite: canEdit(layer, opt.isAdmin)," in side # 구조물 조합 층 쓰기 권한
assert "hopyo.paint();" in side.split("function paint(): void {")[1]
hopyo_side = _text("M02_MasterTemplete_Hopyo_Side.ts")
# 359-1 — [파일 업로드] = 구조물 조합 층 단추 꼴(보통 크기 · m02-layers__btn) · 파일 줄 = 바로 엑셀 보기
up = hopyo_side.split("const upload = createButton({")[1].split("upload.title")[0]
assert 'label: "파일 업로드"' in up and 'upload.classList.add("m02-layers__btn");' in up
assert "size:" not in up and "엑셀 올리기" not in hopyo_side
assert "buildHopyoView" not in hopyo_side
assert 'row.addEventListener("click", () => void showSheet(f));' in hopyo_side
assert (
"onClose:" in hopyo_side
and "onDeleted: opt.canWrite ? () => void refresh() : undefined," in hopyo_side
)
assert 'accept: ".xls,.xlsx"' in hopyo_side and "input.hidden = true;" in hopyo_side
assert "disabled: !opt.canWrite," in hopyo_side and "pageScope()" in hopyo_side
# 718-1 — 제목 = 화면 글 키 · [불러오기] = 구조물 조합 층 단추 꼴 → 본 칸 마법사 · 옛 파일 목록 없음
assert 'L("M02_HopyoImport")' in hopyo_side and "일위대가 실무자료" not in hopyo_side
start = hopyo_side.split("const start = createButton({")[1].split("start.title")[0]
assert 'label: "불러오기"' in start and 'start.classList.add("m02-layers__btn");' in start
assert "buildHopyoWizard()" in hopyo_side and "listHopyo" not in hopyo_side
assert "uploadHopyo" not in hopyo_side and "openHopyoSheet" not in hopyo_side
assert "buildHopyoLedger({ scope, canWrite: opt.canWrite })" in hopyo_side # [별칭 장부]
assert hopyo_side.index("list, ledger]") > 0
wizard = _text("M02_MasterTemplete_Hopyo_Wizard.ts")
assert 'accept: ".xls,.xlsx"' in wizard and "input.hidden = true;" in wizard
assert "accessFor(layer, null, user).canWrite" in wizard # 결과 층 = 쓸 수 있는 층만
assert 'let pick: ManageLayer = "personal";' in wizard # 기본 개인
assert "export const SANGEUN_READY = false;" in wizard # 단가산출 결과 = 715-2 뒤 켬
def test_로직_추가_한_곳과_파일_규칙():
@@ -379,9 +378,9 @@ def test_로직_추가_한_곳과_파일_규칙():
def test_엑셀_보기_창_여는_자리와_늦게_불러옴():
lazy = 'import("./M02_MasterTemplete_Hopyo_Sheet")'
side = _text("M02_MasterTemplete_Hopyo_Side.ts")
assert "await showSheet(got);" in side # 올린 직후 자동
assert lazy in side.split("async function showSheet(")[1].split("async function showLedger(")[0]
side = _text("M02_MasterTemplete_Hopyo_Wizard.ts")
assert "second(pick, got);" in side # 올린 직후 ② 걸음
assert lazy in side.split("function second(")[1].split("async function end(")[0]
check = _text("M02_MasterTemplete_Hopyo_Check.ts")
assert lazy in check and 'label: "엑셀 보기"' in check and "호표: opt.no" in check
assert "엑셀에서 보기" not in check
@@ -517,6 +516,7 @@ def test_명사형_단추와_별칭_장부_풀이_359():
assert f"label: {label}" in ledger, label
assert '"우리에 없음": "로직없음"' in ledger and '"안 씀": "사용안함"' in ledger
mine = ("Hopyo_Side", "Hopyo_Sheet", "Hopyo_Check", "Hopyo_CheckRows", "Hopyo_Ledger")
mine += ("Hopyo_Wizard",)
every = "\n".join(_text(f"M02_MasterTemplete_{n}.ts") for n in mine)
for gone in (
"고친 것 버림",
@@ -532,8 +532,8 @@ def test_명사형_단추와_별칭_장부_풀이_359():
def test_372_2_저장은_개인_층에_삭제는_화면_층_권한():
side = _text("M02_MasterTemplete_Hopyo_Side.ts")
open_ = side.split("sheet.openHopyoSheet({")[1].split("});")[0]
assert 'target: { layer: "personal", owner: null },' in open_
assert "canWrite: true," in open_ # 만들기 = 개인 층 · 화면 층 무관
assert "onDeleted: opt.canWrite ? () => void refresh() : undefined," in open_
"""718-1 — 만들 층 = 마법사에서 고른 층(올린 층과 같음) · [삭제] = 끝내기 → 처음 걸음."""
side = _text("M02_MasterTemplete_Hopyo_Wizard.ts")
open_ = side.split("m.openHopyoSheet({")[1].split("}),")[0]
assert "target: scope," in open_ and "canWrite: true," in open_
assert "onDeleted: () => void first()," in open_
+1
View File
@@ -188,4 +188,5 @@ export const ui_locales_m2 = {
"그 사이 실무문서가 바뀜 — 목록을 다시 받음",
"Practice documents changed — reloaded",
],
M02_HopyoImport: ["일위대가 불러오기", "Import unit-price sheets"],
} as const;