From b1b979494674b97752e9bc6693f5c645c75a08c4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 2 Nov 2026 18:40:45 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(739-1):=20=EB=B3=84=EC=B9=AD=20?= =?UTF-8?q?=EC=9E=A5=EB=B6=80=20=EC=A4=91=EC=95=99=20=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=E2=80=94=20=EC=9E=90=EB=8F=99=20=EC=A7=9D=20=3D=20=EB=A7=88?= =?UTF-8?q?=EC=8A=A4=ED=84=B0(=EC=8A=B9=EC=9D=B8)=20=C2=B7=20=EC=9D=B4=20?= =?UTF-8?q?=EB=B6=88=EB=9F=AC=EC=98=A4=EA=B8=B0=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=9D=B4=EC=9D=80=20=EC=A7=9D=EB=A7=8C=20=C2=B7=20=ED=9A=8C?= =?UTF-8?q?=EC=82=AC=20=C2=B7=20=EA=B0=9C=EC=9D=B8=20=EC=B8=B5=20=EC=A7=9D?= =?UTF-8?q?=20=3D=20=EB=82=B4=20=EC=A0=9C=EC=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 장부 읽는 차례: 자동 정확 열쇠(마스터 · 같은 불러오기 `불러오기` 표시) → 제안 정확 열쇠(`제안` True · 자동 아님) → 품명|단위 느슨 - 만들기 · 단가산출 만들기가 이은 짝에 불러오기 열쇠(`doc_id` 층/주인/HP 키) 남김 · 장부 화면 고침은 그 표시 그대로 둠 - 줄 맞춤: 내 제안 = 숫자 판정 그대로 + 꼬리표 「내 제안」 + `제안`(고르기 창 맨 위 · 한 번 눌러 짝 통째) · 호표 통째 제안 = 체크 안 함 - 장부 화면(회사 · 개인 층) = 「내 제안 목록」 · 승인 칸(대기 · 승인 · 반려 = M01 서명 · 반려 기억) · [삭제] → [거두기] · 마스터 층(M01) 그대로 - M01 별칭 장부 모으기 · 승인 · 반려 길 그대로 · 기존 층 장부 파일 안 지움(제안으로 읽힘) - 낡은 시험 고침: hopyo ledger(아래 층이 이김 → 마스터만 자동 · 제안 · 승인 상태) · hopyo match(별칭 자동 규칙 = 마스터 층 장부로) · hopyo screen(머리 글 · 거두기 단추) - 새 시험 test_739_1_alias_central(층 짝 자동 0 · 내 제안 · 이은 짝 → 제안 · M01 후보 · 승인 뒤 자동 · 반려 상태) Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_Hopyo_Build.py | 5 +- M02_MasterTemplete/M02_Hopyo_Ledger.py | 64 ++++++-- M02_MasterTemplete/M02_Hopyo_Line.py | 27 +++- M02_MasterTemplete/M02_Hopyo_Sangeun.py | 4 +- .../M02_MasterTemplete_Hopyo_Api.ts | 4 + .../M02_MasterTemplete_Hopyo_CheckRows.ts | 4 +- .../M02_MasterTemplete_Hopyo_Choose.ts | 32 ++++ .../M02_MasterTemplete_Hopyo_Ledger.ts | 24 ++- resources/tester/test_739_1_alias_central.py | 147 ++++++++++++++++++ resources/tester/test_m02_hopyo_ledger.py | 32 ++-- resources/tester/test_m02_hopyo_match.py | 15 +- resources/tester/test_m02_hopyo_screen.py | 5 +- 12 files changed, 319 insertions(+), 44 deletions(-) create mode 100644 resources/tester/test_739_1_alias_central.py diff --git a/M02_MasterTemplete/M02_Hopyo_Build.py b/M02_MasterTemplete/M02_Hopyo_Build.py index 615dda78f..1713e0323 100644 --- a/M02_MasterTemplete/M02_Hopyo_Build.py +++ b/M02_MasterTemplete/M02_Hopyo_Build.py @@ -168,6 +168,7 @@ class Builder: raise _bad(f"없는 호표 「제{no}호표」", 404) self.src, self.dry, self._sk, self._maker = Path(source), False, None, None self.strict = True # 남은 줄 있으면 409(붙여넣기 `build_all` = 거짓) + self.doc_id = ledger.doc_id(source, key) # 739-1 이 불러오기에서 이은 짝 # ── 재검사 ─────────────────────────────────────────────────────── def _target(self, raw: dict[str, Any], where: str, line: dict | None = None) -> dict[str, Any]: @@ -395,11 +396,11 @@ class Builder: count |= {"빠짐": sum(1 for x in missing if x["까닭"] != TWICE), "이음": links} count["단가산출"] = sum(1 for x in lines if _is_sk(x)) if not save: - ledger.write(self.folder, book, self.who, file or None) + ledger.write(self.folder, book, self.who, file or None, self.doc_id) head = {"문서": doc, "빠진줄": missing, "남은줄": left} return {**head, "줄수": {**count, "이음후보": linker.near}} ref, again = _save(doc) - wrote = ledger.write(self.folder, book, self.who, file or None) + wrote = ledger.write(self.folder, book, self.who, file or None, self.doc_id) out = {"참조": ref, "다시씀": again, "빠진줄": missing, "장부": {"씀": wrote}} return {**out, "줄수": {**count, "이음후보": linker.near}} diff --git a/M02_MasterTemplete/M02_Hopyo_Ledger.py b/M02_MasterTemplete/M02_Hopyo_Ledger.py index 91312db61..853b4d368 100644 --- a/M02_MasterTemplete/M02_Hopyo_Ledger.py +++ b/M02_MasterTemplete/M02_Hopyo_Ledger.py @@ -6,8 +6,10 @@ 직접 줄 호표 통째 = 열쇠 「호표|품명|규격|단위」. 옛 줄 {로직|자재|빼기, 입력값, 환산, 확정} = 읽을 때 새 꼴로 바꿔 읽음(빼기 = 「안 씀」 · 파일은 다음 쓰기 때 새 꼴). -읽는 차례 = 같은 층 → 위 층(개인 → 회사 → 마스터) · 아래 층이 이김 · 정확 열쇠가 먼저 · 없으면 -품명|단위 느슨 열쇠(`규격다름`). +중앙 관리(739-1) — 자동 짝 = 마스터 층(M01 별칭 장부 승인) · 이 불러오기에서 이은 짝 +(줄 `불러오기` = `doc_id`)만 · 회사 · 개인 층 짝 = 「내 제안」(`제안` True · 자동 아님 · +후보 맨 위 · M01 승인 대기). +읽는 차례 = 자동 정확 열쇠(같은 층 → 위 층) → 제안 정확 열쇠 → 품명|단위 느슨 열쇠(`규격다름`). 쓰는 때 = [확인하고 사전조합 만들기] · 장부 화면 [저장]만 · 쓰기 = 그 층만(권한은 길 몫) · 짝은 서버가 다시 검사(돈 로직 · 그 로직 입력 이름 · M01 자재품목 · 배 0 넘음 · 몫 (0, 1]). """ @@ -101,32 +103,69 @@ def books(folder: str | Path) -> list[tuple[str, str | None, dict[str, dict[str, return [(*refs.where(one), read(one)) for one in chain(folder)] +def doc_id(folder: str | Path, doc: str) -> str: + """불러오기 하나 열쇠 — 「층/주인/HP 키」(장부 줄 `불러오기` · 같은 불러오기 안 자동).""" + layer, owner = refs.where(folder) + return f"{layer}/{owner or ''}/{doc}" + + def recall( - line: dict[str, Any], folder: str | Path, got: list | None = None, whole: bool = False + line: dict[str, Any], + folder: str | Path, + got: list | None = None, + whole: bool = False, + doc: str | None = None, ) -> dict[str, Any] | None: - """줄(`whole` = 호표 통째)의 장부 짝 — `{…줄, 층, 주인, 규격다름}` · 없으면 None. - 정확 열쇠(아래 층 먼저) → 품명|단위 느슨(규격다름 True · 체크 안 함).""" + """줄(`whole` = 호표 통째)의 장부 짝 — `{…줄, 층, 주인, 규격다름, 제안}` · 없으면 None. + 자동 정확 열쇠(마스터 · `doc` 불러오기에서 이은 짝) → 제안 정확 열쇠(회사 · 개인 층 · + `제안` True · 자동 아님) → 품명|단위 느슨(규격다름 True · 체크 안 함).""" full = hopyo_key(line) if whole else key(line) got = books(folder) if got is None else got - for layer, owner, book in got: - if full in book: - return {**book[full], "층": layer, "주인": owner, "규격다름": False} + hits = [(layer, owner, book[full]) for layer, owner, book in got if full in book] + for layer, owner, row in hits: + if layer == "system" or (doc and row.get("불러오기") == doc): + return {**row, "층": layer, "주인": owner, "규격다름": False, "제안": False} + if hits: + layer, owner, row = hits[0] + return {**row, "층": layer, "주인": owner, "규격다름": False, "제안": True} loose = _loose(full) for layer, owner, book in got: for one, row in book.items(): if one.startswith(HOPYO) == whole and _loose(one) == loose: - return {**row, "층": layer, "주인": owner, "규격다름": True} + return {**row, "층": layer, "주인": owner, "규격다름": True, "제안": False} return None +def _states(rows_: list[dict[str, Any]]) -> None: + """내 제안 승인 상태(739-1) — 마스터 줄 = 승인 · 아래 층 줄 = 마스터에 같은 짝(서명) 승인 · + M01 반려 기억 반려 · 그 밖 대기(M01 별칭 장부 `sign` · 반려 기억 그대로).""" + from M01_MasterData import M01_MasterData_Alias as alias # M01 이 장부를 부름 — 늦게 읽음 + + master = read(store.FOLDER) + gone = alias.rejected() + for row in rows_: + if row["층"] == "system": + row["상태"] = "승인" + continue + sig, mine = alias.sign(alias.ALIAS, row), master.get(row["열쇠"]) + if mine and alias.sign(alias.ALIAS, mine) == sig: + row["상태"] = "승인" + elif alias._reject_id(alias.ALIAS, row["열쇠"], sig) in gone: + row["상태"] = "반려" + else: + row["상태"] = "대기" + + def rows(folder: str | Path, writable: bool) -> list[dict[str, Any]]: - """장부 화면 — 이 층 + 위 층 줄 `{열쇠, …줄, 층, 주인, 고침}`(고침 = 이 층 · 쓰기 권한).""" + """장부 화면 — 이 층 + 위 층 줄 `{열쇠, …줄, 층, 주인, 고침, 상태}`(고침 = 이 층 · 쓰기 권한 · + 상태 = 승인 · 대기 · 반려).""" out = [] for at, (layer, owner, book) in enumerate(books(folder)): for full, row in sorted(book.items()): out.append( {"열쇠": full, **row, "층": layer, "주인": owner, "고침": writable and not at} ) + _states(out) return out @@ -239,9 +278,10 @@ def write( entries: dict[str, dict[str, Any] | None], who: Any, file: str | None = None, + doc: str | None = None, ) -> int: """검사 끝난 줄을 층 장부에 씀(None = 지움) — 같은 열쇠는 덮음 · 처음파일은 있던 값 둠 · - 옛 꼴 줄은 이때 새 꼴로 → 쓴 줄 수.""" + `doc` = 이은 불러오기(`doc_id` · 없으면 있던 값) · 옛 꼴 줄은 이때 새 꼴로 → 쓴 줄 수.""" if not entries: return 0 have = read(folder) @@ -254,6 +294,8 @@ def write( continue first = (have.get(full) or {}).get("처음파일") or file out[full] = {**row, "누가": who, "때": stamp, "처음파일": first, "판": version} + if came := doc or (have.get(full) or {}).get("불러오기"): + out[full]["불러오기"] = came hopyo_store.remember(folder, out) return len(entries) diff --git a/M02_MasterTemplete/M02_Hopyo_Line.py b/M02_MasterTemplete/M02_Hopyo_Line.py index 501c1633a..f2b98770a 100644 --- a/M02_MasterTemplete/M02_Hopyo_Line.py +++ b/M02_MasterTemplete/M02_Hopyo_Line.py @@ -17,6 +17,9 @@ 입력값 출처(칸마다) = 별칭 > 블록(옵션 찾기 값이 규격 읽기와 다름) > 규격(`spec_inputs`) > 자동값. 체크 = 처음 제안일 뿐 — 들어감은 [확인하고 사전조합 만들기](다 창)만. 저장 없음 · 셈은 서버 단독. 장부 = `M02_Hopyo_Ledger`(다 창) · 확인된 사전조합 = `M02_Hopyo_Build.confirmed`(다 창). +④ ⑤ 장부 짝 = 자동 짝만(마스터 · 이 불러오기에서 이은 짝 · 739-1) · 회사 · 개인 층 짝 = +「내 제안」 — 숫자 판정 그대로 + 꼬리표 「내 제안」 + `제안 {갈래, 짝, 대상, 층, 때}` +(고르기 창 맨 위 · 한 번 눌러 고름). """ from __future__ import annotations @@ -40,6 +43,7 @@ from M02_MasterTemplete import M02_Labor_Ref as refs from M02_MasterTemplete import M02_MasterTemplete_Store as store SKIP = ("우리에 없음", "안 씀") +MINE = "내 제안" LOOSE = "별칭 · 규격 다름" LEDGER_OFF = "장부와 다름" OUTSIDE = ("%", "노임", "뺌") # 연결 셈 밖(358-3 ②) @@ -55,6 +59,7 @@ class Ctx: self.got = hm.targets() self.units = {int(h["번"]): h for h in self.doc.get("호표") or []} self.books = ledger.books(self.to) + self.doc_id = ledger.doc_id(self.folder, key) self.calc = fit.ensure(folder, key) self.cache = (fit.result(folder, key) or {}).get("블록") or {} self.done = hopyo_build.confirmed(self.to, refs.where(self.folder)) @@ -255,7 +260,8 @@ def match_line(line: dict[str, Any], ctx: Ctx, full: bool = True) -> dict[str, A return {**out, "상태": "호표", "체크": ok, "대상": [target], "글": text} if kind == "노임": return {**out, "상태": "노임", "글": "노임 · 그릇 없음"} - alias = ledger.recall(line, ctx.to, ctx.books) + alias = ledger.recall(line, ctx.to, ctx.books, doc=ctx.doc_id) + mine, alias = (alias, None) if alias and alias.get("제안") else (None, alias) loose, alias = (alias, None) if alias and alias.get("규격다름") else (None, alias) entry = ctx.cache.get(fit.unit_key(line)) if kind not in hm.MATERIAL_KINDS else None if alias and alias.get("갈래") in SKIP: @@ -273,9 +279,20 @@ def match_line(line: dict[str, Any], ctx: Ctx, full: bool = True) -> dict[str, A why = "셈 중" if not ctx.calc["끝"] else fit.NO_NUMBER got = _names_only(line, ctx, why, full) got = _loose(got, loose, line, ctx) if loose and full else got + got = _mine(got, mine, line, ctx) if mine else got return {**out, **(_grouped(got, line, ctx) if full and kind not in hm.MATERIAL_KINDS else got)} +def _mine(got: dict, mine: dict, line: dict, ctx: Ctx) -> dict: + """내 제안(739-1 · 승인 전 회사 · 개인 층 짝) — 자동 아님 · 꼬리표 + `제안`(고르기 맨 위).""" + pairs = [p for p in mine.get("짝") or [] if p.get("로직") or p.get("자재")] + kind = mine.get("갈래") + word = MINE if kind == "짝" else f"{MINE} — {kind}" + head = {"갈래": kind, "짝": pairs, "층": mine.get("층"), "때": mine.get("때")} + targets = [_pair(p, line, ctx) for p in pairs] + return {**got, "꼬리표": [*(got.get("꼬리표") or []), word], "제안": {**head, "대상": targets}} + + def _grouped(got: dict, line: dict, ctx: Ctx) -> dict: """묶음 표시(358-1) — 후보 · 이름후보(옛 324 다섯)마다 뜻겹침 · 출처같음(조항 ≥ 0.9) · 책 · 차례 · 무게 · 상태 그대로(아무것도 숨기지 않음 · 묶음은 화면 몫).""" @@ -356,12 +373,12 @@ def _aliased(line: dict, ctx: Ctx, alias: dict, entry: dict | None) -> dict: def whole(hopyo: dict, ctx: Ctx) -> dict | None: """직접 줄 호표 통째 제안 — 장부 짝(호표 열쇠) 먼저 · 맞춤셈 「호표:번」.""" head = {k: hopyo.get(k) for k in ("이름", "규격", "단위")} - alias = ledger.recall(head, ctx.to, ctx.books, whole=True) + alias = ledger.recall(head, ctx.to, ctx.books, whole=True, doc=ctx.doc_id) if alias and alias.get("갈래") == "짝" and alias.get("짝") and not alias.get("규격다름"): - return { + return { # 내 제안(739-1) = 체크 안 함 · 한 번 눌러 고름 "대상": [_pair(p, head, ctx) for p in alias["짝"]], - "글": "별칭 장부 짝", - "체크": True, + "글": MINE if alias.get("제안") else "별칭 장부 짝", + "체크": not alias.get("제안"), } entry = ctx.cache.get(f"호표:{hopyo['번']}") if not entry or not entry.get("차례"): diff --git a/M02_MasterTemplete/M02_Hopyo_Sangeun.py b/M02_MasterTemplete/M02_Hopyo_Sangeun.py index e5fa7d86c..9634dc0a2 100644 --- a/M02_MasterTemplete/M02_Hopyo_Sangeun.py +++ b/M02_MasterTemplete/M02_Hopyo_Sangeun.py @@ -306,6 +306,8 @@ def build(folder: Path, key: str, who: Any = None, picks: Any = None) -> dict[st found = ledger.recall(line, folder, books) if no in chosen or found is None or found.get("규격다름"): alias[ledger.key(line)] = _alias(line, plan) - out["별칭"] = ledger.write(folder, alias, who, maker.hp.get("파일") or None) + out["별칭"] = ledger.write( + folder, alias, who, maker.hp.get("파일") or None, ledger.doc_id(folder, key) + ) out["같은것씀"], out["위층같음"] = maker.reused, maker.above return out diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts index 3b925e3f0..eb3b487db 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts @@ -153,6 +153,8 @@ export interface HopyoMatch { 갈리는칸?: string[]; 별칭?: { 층: Layer; 주인: string | null; 규격다름?: boolean; 근거다름?: boolean }; 뺌?: string; + /** 739-1 내 제안(승인 전 회사 · 개인 층 장부 짝) — 자동 아님 · 고르기 창 맨 위 */ + 제안?: { 갈래: string; 짝: LedgerPair[]; 대상: HopyoPart[]; 층: Layer; 때: string | null }; } /** 숫자 판정 셈 — 끝 · 한 블록 · 모두 · 까닭(우리 로직 준비 중 등) */ @@ -405,6 +407,8 @@ export interface LedgerRow extends LedgerEntry { 층: Layer; 주인: string | null; 고침: boolean; + /** 739-1 M01 승인 상태 — 마스터 줄 = 승인 */ + 상태?: "승인" | "대기" | "반려"; } const enc = encodeURIComponent; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_CheckRows.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_CheckRows.ts index 577378746..6f93225ef 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_CheckRows.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_CheckRows.ts @@ -189,7 +189,9 @@ function actsCell(line: HopyoLine, parts: HopyoPart[], ctx: RowsContext): HTMLEl return el("td", { className: "m02-hopyo__acts", children: [ - ...(ctx.canWrite && m.상태 !== "%" ? [ghost("고르기", () => ctx.choose(line))] : []), + ...(ctx.canWrite && m.상태 !== "%" + ? [ghost(m.제안?.짝.length ? "고르기 · 내 제안" : "고르기", () => ctx.choose(line))] + : []), ghost("원문 보기", () => ctx.source(line)), ...(line.근거 ? [ghost("엑셀 보기", () => ctx.sheet(line.근거!.행))] : []), ...options, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Choose.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Choose.ts index 4b75325b0..0b299e0c1 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Choose.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Choose.ts @@ -224,6 +224,37 @@ export function openHopyoChoose(opt: ChooseOptions): void { ), ); const groups = candGroups(line.맞춤, material); + // 739-1 내 제안 — 맨 위 · 한 번 눌러 짝 통째로(입력값 · 배 · 몫 그대로) + const mine = line.맞춤.제안?.짝.length ? line.맞춤.제안 : null; + const mineRow = mine + ? el("div", { + attrs: { "data-group": "내 제안" }, + children: [ + note("내 제안 — 승인 전(M01 별칭 장부 승인 뒤 자동)"), + el("div", { + className: "m02-hopyo__cand", + attrs: { "data-cand": "내 제안" }, + children: [ + createButton({ + label: mine.대상.map((c) => candText(c, new Set())).join(" + "), + variant: "ghost", + size: "sm", + onClick: () => + finish( + mine.짝.map((p) => ({ + ...(p.로직 ? { 로직: p.로직 } : { 자재: p.자재 }), + 입력값: p.입력값 ?? {}, + 배: p.배, + 몫: p.몫 ?? null, + })), + ), + }), + el("span", { className: "m02-hopyo__state is-별칭", text: "내 제안" }), + ], + }), + ], + }) + : null; const twins = sameNames(groups); const row = (c: HopyoTarget): HTMLElement => { const share = el("input", { @@ -332,6 +363,7 @@ export function openHopyoChoose(opt: ChooseOptions): void { ? "후보 — 하나 고름 · 여럿은 체크(몫 칸) 뒤 [일괄 적용]" : "가까운 것 없음 — 아래 검색", ), + ...(mineRow ? [mineRow] : []), ...sections, el("div", { className: "ui-toolbar m02-hopyo__bar", children: [together, ...more] }), ); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Ledger.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Ledger.ts index c75e7e7c1..523be4af8 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Ledger.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Ledger.ts @@ -7,8 +7,11 @@ * 위 층 줄 [내 층 복사] · 갈래 글 = 확인 창 드롭다운과 같음(짝 · 로직없음 · 사용안함 · 저장 값 그대로) * · 고친 것 = sessionStorage `m02-hopyo-ledger:{층}:{주인}` → [저장] 때만 `PUT`(서버가 짝을 다시 검사) * · 안 둠 = 발주처 칸 · 자동 올림 · 자동 합침 · 쓰인 횟수 · 회사로 올리기. + * · 739-1 중앙 관리 — 회사 · 개인 층 = 「내 제안 목록」(보기 · 고치기 · [거두기] · 승인 상태 대기 · 승인 · 반려) · + * 자동 짝 = 마스터(M01 별칭 장부 승인)만 · 마스터 층(M01 화면) = 별칭 장부 그대로 * ========================================================================== */ +import { createBadge, type BadgeTone } from "@ui/ui_template_badge"; import { createButton, el, showToast } from "@ui/ui_template_elements"; import { loadingError, showLoading } from "@ui/ui_template_loading"; import { t as L } from "@ui/ui_template_locale"; @@ -38,6 +41,9 @@ export const KIND_TEXT: Record = { "안 씀": "사용안함", }; const LAYER_TEXT: Record = { system: "마스터", company: "회사", personal: "개인" }; +/** 739-1 회사 · 개인 층 장부 화면 제목 */ +export const MINE_TITLE = "내 제안 목록"; +const STATE_TONE: Record = { 승인: "success", 대기: "warning", 반려: "danger" }; type Edits = Record; /** 화면 한 줄 — 서버 줄 위에 고친 것 */ export type LedgerView = LedgerRow & { 고친?: boolean }; @@ -139,6 +145,7 @@ export function buildHopyoLedger(opt: LedgerOptions): HTMLElement { attrs: { "data-hopyo-ledger": "" }, }); const k = ledgerKey(opt.scope); + const master = opt.scope.layer === "system"; let edits = loadEdits(k); let rows: LedgerRow[] = []; const search = el("input", { @@ -249,7 +256,7 @@ export function buildHopyoLedger(opt: LedgerOptions): HTMLElement { if (mine) acts.push( createButton({ - label: "삭제", + label: master ? "삭제" : "거두기", variant: "danger", size: "sm", onClick: () => { @@ -282,6 +289,10 @@ export function buildHopyoLedger(opt: LedgerOptions): HTMLElement { pairCell(r, mine), td(when), td(r.고침 ? "내 층" : (LAYER_TEXT[r.층] ?? r.층)), + el("td", { + attrs: { "data-state": r.상태 ?? "" }, + children: r.고친 || !r.상태 ? [] : [createBadge(r.상태, STATE_TONE[r.상태])], + }), el("td", { className: "m02-hopyo__acts", children: acts }), ], }); @@ -296,7 +307,7 @@ export function buildHopyoLedger(opt: LedgerOptions): HTMLElement { body.replaceChildren( ...(shown.length ? shown.map(rowOf) - : [el("tr", { children: [el("td", { text: "장부 줄 없음", attrs: { colspan: "8" } })] })]), + : [el("tr", { children: [el("td", { text: "장부 줄 없음", attrs: { colspan: "9" } })] })]), ); } search.addEventListener("input", paint); @@ -332,8 +343,12 @@ export function buildHopyoLedger(opt: LedgerOptions): HTMLElement { el("div", { className: "ui-toolbar m02-hopyo__bar", children: [ - el("strong", { text: LEDGER_TITLE }), - note(`${LEDGER_HINT} · ${L("M02_PrecomboTitle")} 생성 때 남음 · 아래 층이 이김`), + el("strong", { text: master ? LEDGER_TITLE : MINE_TITLE }), + note( + master + ? `${LEDGER_HINT} · 여기 짝 = 모든 사용자 불러오기에 자동` + : `내 층 짝 = 내 제안(다음 불러오기에서 후보 맨 위 · 자동 아님) · M01 별칭 장부 승인 뒤 자동 · ${L("M02_PrecomboTitle")} 생성 때 남음`, + ), search, kind, ...(opt.canWrite ? [save, drop] : []), @@ -356,6 +371,7 @@ export function buildHopyoLedger(opt: LedgerOptions): HTMLElement { "우리 로직 · 배 · 몫 · 옵션", "누가 · 때 · 처음 파일", "층", + "승인", "", ].map((t) => el("th", { text: t })), }), diff --git a/resources/tester/test_739_1_alias_central.py b/resources/tester/test_739_1_alias_central.py new file mode 100644 index 000000000..9f1949ab9 --- /dev/null +++ b/resources/tester/test_739_1_alias_central.py @@ -0,0 +1,147 @@ +"""별칭 장부 중앙 관리(PLAN 739-1 · `M02_Hopyo_Ledger` · `M02_Hopyo_Line` · +`M01_MasterData_Alias`) — 자동 짝 = 마스터(승인) · 이 불러오기에서 이은 짝만 · 회사 · 개인 층 짝 = +「내 제안」(자동 0 · 꼬리표 · 고르기 맨 위 `제안`) · 이은 짝 → 층 장부(불러오기 표시) → M01 후보 · +승인 뒤 다음 불러오기 자동 · 장부 화면 승인 상태(대기 · 승인 · 반려) · master_data 원본 그대로. +층 · storage 는 임시 폴더. +""" + +from M01_MasterData import M01_MasterData_Alias as alias +from M02_MasterTemplete import M02_Hopyo_Fit as fit +from M02_MasterTemplete import M02_Hopyo_Ledger as ledger +from M02_MasterTemplete import M02_Hopyo_Store as hs +from M02_MasterTemplete import M02_Template_Layers as layers + +# 올리기 길 시험의 `client` 마당(임시 storage · 마스터 층)을 같이 씀 +# ruff: noqa: F811 +from resources.tester.test_m02_hopyo_excel import URL, _post, client, master_hash # noqa: F401 + +DIG = {"이름": "구조물터파기(인력100%)", "규격": "토사", "단위": "㎥"} +DIG_KEY = "구조물터파기(인력100%)|토사|m3" +WHO = {"id": 42, "이름": "갑"} +ADMIN = {"id": 1, "이름": "관리자"} +LEDGER = "/api/m02/layers/{}/hopyo-ledger" +FROM_LEDGER = ("별칭", "뺌") # 장부가 자동으로 정한 줄 상태 + + +def _pair(code): + return ledger.check(DIG_KEY, {"갈래": "짝", "짝": [{"로직": code, "배": 1}]}) + + +def _candidate(key, code): + got = [ + x + for x in alias.gather()["후보"] + if x["열쇠"] == key and x["종류"] == alias.ALIAS and x["짝"][0]["로직"] == code + ] + assert len(got) == 1, got + return got[0] + + +def test_장부_층_짝은_제안_이_불러오기_짝은_자동_승인_뒤_모두_자동(client): + before = master_hash() + mine = layers.personal_dir(7, 42) + here = ledger.doc_id(mine, "HP-000001") + assert here == "personal/7/42/HP-000001" + ledger.write(mine, {DIG_KEY: _pair("GF000101")}, WHO, "가.xls", here) + got = ledger.recall(DIG, mine, doc=here) + assert (got["층"], got["제안"]) == ("personal", False) # 이 불러오기에서 이은 짝 = 바로 씀 + later = ledger.doc_id(mine, "HP-000002") # 다음 불러오기 + for doc in (later, None): + got = ledger.recall(DIG, mine, doc=doc) + assert (got["층"], got["짝"][0]["로직"], got["제안"]) == ("personal", "GF000101", True) + ledger.put(mine, {DIG_KEY: {**_pair("GF000101"), "엑셀조항": []}}, WHO) # 장부 화면 고침 + assert ledger.read(mine)[DIG_KEY]["불러오기"] == here # 이은 불러오기 표시는 남음 + assert [r["상태"] for r in ledger.rows(mine, True)] == ["대기"] + # M01 후보에 보임 → 승인 → 마스터 → 다음 불러오기 · 다른 회사 새 사용자도 자동 + sig = _candidate(DIG_KEY, "GF000101")["서명"] + alias.approve(alias.ALIAS, DIG_KEY, sig, ADMIN) + for folder, doc in ((mine, later), (layers.personal_dir(9, 99), None)): + got = ledger.recall(DIG, folder, doc=doc) + assert (got["층"], got["짝"][0]["로직"], got["제안"]) == ("system", "GF000101", False) + assert [(r["층"], r["상태"]) for r in ledger.rows(mine, True)] == [ + ("personal", "승인"), + ("system", "승인"), + ] + # 다른 짝 제안 → 반려 = 상태 반려 · 여전히 내 제안(자동 아님) + other = layers.personal_dir(7, 43) + ledger.write(other, {DIG_KEY: _pair("GF000103")}, {"id": 43}, "나.xls") + alias.reject(alias.ALIAS, DIG_KEY, _candidate(DIG_KEY, "GF000103")["서명"], ADMIN) + assert ledger.rows(other, True)[0]["상태"] == "반려" + got = ledger.recall(DIG, other) + assert (got["층"], got["짝"][0]["로직"]) == ("system", "GF000101") # 자동 = 마스터 + assert master_hash() == before + + +def _hopyo(client, no): + res = client.get(f"{URL.format('personal')}/HP-000001/{no}") + assert res.status_code == 200, res.text + return res.json()["호표"] + + +def _lines(client, no): + return {one["이름"]: one["맞춤"] for one in _hopyo(client, no)["줄"]} + + +def _auto(client, no): + """장부가 자동으로 정한 줄 수.""" + return sum(1 for m in _lines(client, no).values() if m["상태"] in FROM_LEDGER) + + +def test_불러오기_내_제안_후보_이은_짝_승인_흐름(client): + before = master_hash() + assert _post(client).status_code == 200 + mine = layers.personal_dir(7, 42) + fit.run_job(mine, "HP-000001") + plain = _lines(client, 3)["구조물터파기(인력100%)"] + assert plain["상태"] not in FROM_LEDGER and "제안" not in plain + # 개인 층 장부 짝(옛 층 장부 · 승인 전) = 자동 0 · 꼬리표 「내 제안」 · 고르기 맨 위 `제안` + row = {"갈래": "짝", "짝": [{"로직": "GC000083", "배": 1}]} + res = client.put(LEDGER.format("personal"), json={"줄": {DIG_KEY: row}}) + assert res.status_code == 200, res.text + assert _auto(client, 3) == 0 + got = _lines(client, 3)["구조물터파기(인력100%)"] + assert (got["상태"], got["체크"]) == (plain["상태"], plain["체크"]) # 숫자 판정 그대로 + assert "내 제안" in got["꼬리표"] and "별칭" not in got + assert got["제안"]["짝"][0]["로직"] == "GC000083" and got["제안"]["층"] == "personal" + assert got["제안"]["대상"][0]["로직"] == "GC000083" + # 확인하고 만들기 = 이은 짝 → 이 불러오기 안에서는 바로 자동 · 층 장부(제안)에 불러오기 표시 + hopyo = _hopyo(client, 3) + at = next(x["차례"] for x in hopyo["줄"] if x["이름"] == "구조물터파기(인력100%)") + body = {"층": "personal", "키": "HP-000001", "번": 3} + lines = [{"차례": at, "대상": [{"로직": "GC000083", "입력값": {}, "배": 1}]}] + left = client.post( + "/api/m02/layers/personal/hopyo-unlinked", json={**body, "줄": lines} + ).json()["남은줄"] + lines += [{"차례": x["차례"], "뺌": "우리에 없음"} for x in left] + res = client.post("/api/m02/layers/personal/hopyo-build", json={**body, "줄": lines}) + assert res.status_code == 200, res.text + book = ledger.read(mine) + assert book[DIG_KEY]["불러오기"] == "personal/7/42/HP-000001" + got = _lines(client, 3)["구조물터파기(인력100%)"] + assert got["상태"] == "별칭" and got["별칭"]["층"] == "personal" + # 다음 불러오기(다른 HP) = 승인 전 자동 0 + nxt = {k: {**v, "불러오기": "personal/7/42/HP-000009"} for k, v in book.items()} + hs.remember(mine, nxt) # 장부 파일 그대로 — 다른 불러오기에서 이은 짝 + assert _auto(client, 3) == 0 + # M01 후보에 보임 → 승인 → 다음 불러오기 자동(마스터) + sig = _candidate(DIG_KEY, "GC000083")["서명"] + alias.approve(alias.ALIAS, DIG_KEY, sig, ADMIN) + got = _lines(client, 3)["구조물터파기(인력100%)"] + assert got["상태"] == "별칭" and got["별칭"]["층"] == "system" and "제안" not in got + assert _auto(client, 3) == 1 + assert master_hash() == before + + +def test_화면_내_제안_글(): + """고르기 창 맨 위 「내 제안」(한 번 눌러 짝 통째) · 장부 화면 = 내 제안 목록 · 승인 칸.""" + from pathlib import Path + + m02 = Path(__file__).resolve().parents[2] / "M02_MasterTemplete" + choose = (m02 / "M02_MasterTemplete_Hopyo_Choose.ts").read_text(encoding="utf-8") + assert choose.index("...(mineRow ? [mineRow] : [])") < choose.index("...sections") + assert '"data-group": "내 제안"' in choose and "mine.짝.map((p)" in choose + rows = (m02 / "M02_MasterTemplete_Hopyo_CheckRows.ts").read_text(encoding="utf-8") + assert '"고르기 · 내 제안"' in rows + book = (m02 / "M02_MasterTemplete_Hopyo_Ledger.ts").read_text(encoding="utf-8") + assert 'MINE_TITLE = "내 제안 목록"' in book and '"승인",' in book + assert "createBadge(r.상태, STATE_TONE[r.상태])" in book diff --git a/resources/tester/test_m02_hopyo_ledger.py b/resources/tester/test_m02_hopyo_ledger.py index d60b5b239..761a74110 100644 --- a/resources/tester/test_m02_hopyo_ledger.py +++ b/resources/tester/test_m02_hopyo_ledger.py @@ -1,5 +1,5 @@ """일위대가 실무자료 별칭 장부(PLAN 338-3 · `M02_Hopyo_Ledger`) — 옛 꼴 바꿔 읽음 · 새 꼴 · -층 차례(아래 층이 이김) · 느슨 찾기(규격다름) · 우리에 없음 · 안 씀 · 호표 통째 열쇠 · +층 차례(739-1 자동 = 마스터 · 이 불러오기 짝 · 아래 층 = 제안) · 느슨 찾기(규격다름) · 우리에 없음 · 안 씀 · 호표 통째 열쇠 · 위 층 읽기만 · 재검사 · 쓰기(처음파일 둠 · 옛 꼴 새로 씀) · master_data 원본 그대로. 층은 임시 폴더. """ @@ -65,21 +65,26 @@ def test_옛_꼴_바꿔_읽음(client): assert got["표지석||개"]["짝"] == [{"자재": "MT004258", "입력값": {}, "배": 1, "몫": None}] -def test_층_차례_아래_층이_이김_느슨_찾기(client): +def test_층_차례_마스터만_자동_아래_층은_제안_느슨_찾기(client): mine, company, system = ledger.chain(layers.personal_dir(7, 42)) assert (company, system) == (layers.company_dir(7), store.FOLDER) new = {"갈래": "짝", "짝": [{"로직": "GF000103", "입력값": {}, "배": 1, "몫": None}]} _put_raw(system, {DIG_KEY: new, "잔토처리(인력100%)||m3": {"갈래": "우리에 없음"}}) _put_raw(company, {DIG_KEY: {**new, "짝": [{"로직": "GF000102", "배": 1}]}}) - got = ledger.recall(DIG, mine) - assert (got["층"], got["주인"], got["짝"][0]["로직"], got["규격다름"]) == ( - "company", - "7", - "GF000102", + got = ledger.recall(DIG, mine) # 739-1 — 회사 층 짝이 있어도 자동 = 마스터(승인) + assert (got["층"], got["주인"], got["짝"][0]["로직"], got["규격다름"], got["제안"]) == ( + "system", + None, + "GF000103", + False, False, ) + _put_raw(system, {"잔토처리(인력100%)||m3": {"갈래": "우리에 없음"}}) + got = ledger.recall(DIG, mine) + assert (got["층"], got["짝"][0]["로직"], got["제안"]) == ("company", "GF000102", True) _put_raw(mine, {DIG_KEY: {"로직": "GF000101", "환산": 1}}) - assert ledger.recall(DIG, mine)["짝"][0]["로직"] == "GF000101" # 같은 층 먼저 + got = ledger.recall(DIG, mine) + assert (got["짝"][0]["로직"], got["제안"]) == ("GF000101", True) # 제안 = 같은 층 먼저 rock = {**DIG, "규격": "경암"} loose = ledger.recall(rock, mine) assert loose["규격다름"] is True and loose["층"] == "personal" # 품명|단위 느슨 @@ -90,12 +95,11 @@ def test_층_차례_아래_층이_이김_느슨_찾기(client): books = ledger.books(mine) assert ledger.recall(DIG, mine, books)["짝"][0]["로직"] == "GF000101" # 한 번 읽어 넘김 rows = ledger.rows(mine, True) - assert [(r["층"], r["고침"]) for r in rows] == [ - ("personal", True), - ("company", False), - ("system", False), - ("system", False), - ] # 위 층 = 읽기만 + assert [(r["층"], r["고침"], r["상태"]) for r in rows] == [ + ("personal", True, "대기"), + ("company", False, "대기"), + ("system", False, "승인"), + ] # 위 층 = 읽기만 · 739-1 승인 상태 assert not any(r["고침"] for r in ledger.rows(mine, False)) diff --git a/resources/tester/test_m02_hopyo_match.py b/resources/tester/test_m02_hopyo_match.py index 111e375b9..dfc80ec41 100644 --- a/resources/tester/test_m02_hopyo_match.py +++ b/resources/tester/test_m02_hopyo_match.py @@ -157,11 +157,18 @@ def _ledger(client, rows, layer="personal"): assert res.status_code == 200, res.text +def _master(client, rows): + """739-1 자동 짝 = 마스터(승인) 장부만 — 시스템 관리자로 마스터 층에 씀.""" + client.who.update(role="SYSTEM_ADMIN") + _ledger(client, rows, "system") + client.who.update(role="USER") + + def test_별칭_체크_규칙(client): _ready(client) dig = "구조물터파기(인력100%)|토사|m3" remicon = "레미콘타설(장비사용)|철근구조물-소량타설|m3" - _ledger( + _master( client, { dig: {"갈래": "짝", "짝": [{"로직": "GC000083", "배": 1}]}, @@ -172,7 +179,7 @@ def test_별칭_체크_규칙(client): ) got = _hopyo(client, 3)["구조물터파기(인력100%)"] assert got["상태"] == "별칭" and got["체크"] is True and got["대상"][0]["출처"] == {} - assert got["별칭"]["층"] == "personal" and not got["별칭"]["근거다름"] + assert got["별칭"]["층"] == "system" and not got["별칭"]["근거다름"] first = _hopyo(client, 1) other = first["레미콘타설(장비사용)"] # 숫자 판정 맞음 = GC000198 ≠ 장부 짝 → 판정 먼저(358-3) assert other["상태"] == "맞음" and other["체크"] is True and "별칭" not in other @@ -182,14 +189,14 @@ def test_별칭_체크_규칙(client): assert loose["상태"] == "맞음" and "별칭" not in loose and "별칭 · 규격 다름" in loose["꼬리표"] assert all("별칭" not in t["출처"].values() for t in loose["대상"]) assert "GC000211" in {c["로직"] for c in loose["후보"]} | {t["로직"] for t in loose["대상"]} - _ledger( + _master( client, {remicon: {"갈래": "짝", "짝": [{"로직": "GC000198", "입력값": {"소량감률": 50}}]}} ) plain = _hopyo(client, 7)["레미콘타설(장비사용)"] # 무근구조물 ↔ 장부 철근구조물-소량타설 assert plain["상태"] == "맞음" and plain["체크"] is True and "별칭" not in plain assert plain["대상"][0]["로직"] == "GC000198" and plain["대상"][0]["입력값"]["소량감률"] == 0 assert "별칭 · 규격 다름" in plain["꼬리표"] - _ledger(client, {dig: {"갈래": "짝", "짝": [{"로직": "GF000101", "배": 1}]}}) + _master(client, {dig: {"갈래": "짝", "짝": [{"로직": "GF000101", "배": 1}]}}) off = _hopyo(client, 3)["구조물터파기(인력100%)"] # 산림 로직 ↔ 엑셀 건설 3-2-1 assert off["별칭"]["근거다름"] and off["체크"] is False and "근거 다름" in off["꼬리표"] diff --git a/resources/tester/test_m02_hopyo_screen.py b/resources/tester/test_m02_hopyo_screen.py index 605f3d987..429ecb39a 100644 --- a/resources/tester/test_m02_hopyo_screen.py +++ b/resources/tester/test_m02_hopyo_screen.py @@ -514,10 +514,11 @@ def test_명사형_단추와_별칭_장부_풀이_359(): 갈래 글 = 확인 창 드롭다운과 같음(저장 값 그대로).""" ledger = _text("M02_MasterTemplete_Hopyo_Ledger.ts") hint = "엑셀 품명 ↔ 우리 로직 · 자재 짝을 기억해 두었다가 같은 품명이 나오면 먼저 제안" - assert hint in ledger and "note(`${LEDGER_HINT}" in ledger + assert hint in ledger and "`${LEDGER_HINT}" in ledger side = _text("M02_MasterTemplete_Hopyo_Side.ts") assert "title: LEDGER_HINT" in side - for label in ('"변경 취소"', '"항목 추가"', '"내 층 복사"', '"삭제"'): + assert 'label: master ? "삭제" : "거두기"' in ledger # 739-1 내 제안 = 거두기 + for label in ('"변경 취소"', '"항목 추가"', '"내 층 복사"'): assert f"label: {label}" in ledger, label assert '"우리에 없음": "로직없음"' in ledger and '"안 씀": "사용안함"' in ledger mine = ("Hopyo_Side", "Hopyo_Sheet", "Hopyo_Check", "Hopyo_CheckRows", "Hopyo_Ledger") From 5e2ac2ae79aa95415cb4d1309776928401708d46 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 2 Nov 2026 18:44:12 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(736-3):=20=EB=8B=A8=EA=B0=80=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=C2=B7=20=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20[?= =?UTF-8?q?=ED=95=AD=EB=AA=A9=20=EC=B6=94=EA=B0=80]=20=EA=B8=B0=EA=B3=84(M?= =?UTF-8?q?01=20EQ)=20=EA=B3=A0=EB=A6=84=20=E2=80=94=20=EA=B3=A0=EC=A0=95?= =?UTF-8?q?=20=EC=A4=84(=EC=9D=B4=EC=9D=8C=20=EA=B8=B0=EA=B3=84=20=C2=B7?= =?UTF-8?q?=20=EB=8B=A8=EA=B0=80=20=3D=20=ED=95=9C=20=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=EB=85=B8=20=C2=B7=20=EC=9E=AC=20=C2=B7=20=EA=B2=BD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 찾아 넣기 창 기계 줄 누름 = 서버 `/logic-lib/machine-hourly` 로 한 시간 값을 받아 고정 줄 단가로 - 일위대가 = 이음 기계 고정 줄 · 단가산출 = 더한줄 고정 줄(수량 1) — 같은 금액 - 서버 `M02_Machine_Hourly`(기계경비 로직 짝 차례 첫째 · 읽기만) · 시험 추가 Co-Authored-By: Claude Sonnet 5.5 --- M02_MasterTemplete/M02_Logic_Lib_Read.py | 7 ++ M02_MasterTemplete/M02_Machine_Hourly.py | 72 +++++++++++++++++++ .../M02_MasterTemplete_Find_Pick.ts | 36 +++++++++- .../M02_MasterTemplete_Labor_Pick.ts | 1 + .../M02_MasterTemplete_Sangeun_Pick.ts | 9 ++- resources/tester/test_721_find_pick.py | 41 ++++++++++- 6 files changed, 159 insertions(+), 7 deletions(-) create mode 100644 M02_MasterTemplete/M02_Machine_Hourly.py diff --git a/M02_MasterTemplete/M02_Logic_Lib_Read.py b/M02_MasterTemplete/M02_Logic_Lib_Read.py index ae6231d52..751134d50 100644 --- a/M02_MasterTemplete/M02_Logic_Lib_Read.py +++ b/M02_MasterTemplete/M02_Logic_Lib_Read.py @@ -11,6 +11,7 @@ from __future__ import annotations from fastapi import APIRouter, HTTPException from M01_MasterData import M01_MasterData_Router as m01 +from M02_MasterTemplete import M02_Machine_Hourly as machine router = APIRouter(prefix="/api/m02/logic-lib", tags=["M02 Logic Lib Read"]) @@ -71,3 +72,9 @@ def post_calc(body: m01.CalcBody) -> dict: @router.post("/text") def post_text(body: m01.CalcBody) -> dict: return m01.post_text(body) + + +@router.get("/machine-hourly") +def get_machine_hourly(key: str) -> dict: + """기계(EQ) 한 시간 값 노 · 재 · 경(736-3 · [항목 추가] 기계 고정 줄 단가).""" + return machine.hourly(key) diff --git a/M02_MasterTemplete/M02_Machine_Hourly.py b/M02_MasterTemplete/M02_Machine_Hourly.py new file mode 100644 index 000000000..71b6955b1 --- /dev/null +++ b/M02_MasterTemplete/M02_Machine_Hourly.py @@ -0,0 +1,72 @@ +"""M02 기계(M01 EQ) 한 시간 값 — [항목 추가] 창에서 기계를 고르면 고정 줄 단가(PLAN 736-3). + +기계 줄 = 일위대가 · 단가산출 더한줄의 고정 줄(이음 기계 EQ · 수량 1) — 단가 = 이 값(노 · 재 · 경). +셈 = M01 「건설기계」 장 기계경비 로직 가운데 그 기종을 받는 짝 차례 첫째 +(`로직(GC, 기계='원문번호')` · 옵션은 기본값). 짝 차례 · 셈은 +`DataFlow/M02_DF_Values._machine_hourly` 와 같은 규칙(그 폴더는 시험 길이라 따로 둠). +마스터 파일은 읽기만 · 로그인 사용자 모두. +""" + +from __future__ import annotations + +from typing import Any + +from M01_MasterData import M01_MasterData_Store as store + +cm = store.cm +_PLAIN = {"기계", "지역", "보정작업", "조종원직종"} # 이 밖의 입력 = 부착 장비 전용 + + +def _family() -> list[dict]: + """기계 시간당 경비 로직 — 「건설기계」 장 파일 안 · 입력 「기계」 고르기가 있는 돈 로직.""" + return [ + row + for name, data in store.loaded()[0].items() + if data.get("그룹") == "로직" and "건설기계" in name + for row in data.get("줄") or [] + if str(row.get("결과단위") or "").startswith("원") + and any(i.get("이름") == "기계" and i.get("고르기") for i in row.get("입력") or []) + ] + + +def _codes(row: dict) -> list: + return next(i["고르기"] for i in row["입력"] if i.get("이름") == "기계") + + +def _rank(row: dict) -> tuple: + """부착 전용 · 손료만 · 보정 · 기종 수 · 키 — 작을수록 먼저.""" + names = [i.get("이름") for i in row.get("입력") or []] + attach = any(n not in _PLAIN for n in names) + loss = "지역" not in names + return (attach, loss, loss and "보정작업" in names, len(_codes(row)), str(row.get("키"))) + + +def hourly(key: str) -> dict[str, Any]: + """`{ok, 이름, 규격, 노무비, 재료비, 경비, 계}` · 못 셈 = `{ok: False, reason}`.""" + if not key.startswith("EQ"): + return {"ok": False, "reason": f"「{key}」 는 기계 아님"} + try: + row = store.loaded()[1].get(key) + except cm.mf.FormulaError as e: + return {"ok": False, "reason": str(e)} + code = str(row.get("원문번호") or "") + fit = [r for r in _family() if code in [str(c) for c in _codes(r)]] + if not fit: + return {"ok": False, "reason": f"「{row.get('이름')}」 은 시간당 경비를 셀 자료가 없음"} + logic = min(fit, key=_rank) + args = "".join( + f", {i['이름']}='{(i.get('고르기') or [''])[0]}'" + for i in logic.get("입력") or [] + if i.get("이름") != "기계" + ) + call = f"로직({logic['키']}, 기계='{code}'{args})" + line = {"종류": "로직", "요소": call, "이름": "기계", "단위": "시간", "수량": "1"} + holder = { + "키": "", "원문번호": "", "이름": "기계 시간", "결과단위": "원/식", "출처": "자체", + "입력": [], "중간": [], "호표": [line], "덧줄": [], "끝수": None, + } # fmt: skip + got = store.calc("", {}, holder, None) + if not got.get("ok") or "sums" not in got: + return {"ok": False, "reason": got.get("reason") or "셈 못 함"} + sums = {k: float(got["sums"][k]) for k in (*cm.mf.COST_ITEMS, "계")} + return {"ok": True, "이름": row.get("이름"), "규격": row.get("규격"), **sums} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Find_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Find_Pick.ts index 569c17244..21852349c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Find_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Find_Pick.ts @@ -49,6 +49,8 @@ export interface M01Element { 규격: string; 단위: string; 값: number | null; + /** 기계(736-3) — 한 시간 노 · 재 · 경(서버 `/logic-lib/machine-hourly` · 고르는 순간 받음) */ + 단가?: Record<"노무비" | "재료비" | "경비", number>; } /** 고를 수 있는 종류 — logic = M01 단가산출 · 기초 로직 · labor = 인력 · material = 재료 · machine = 기계 · sk = M02 단가산출 · pre = 일위대가 */ @@ -80,6 +82,16 @@ export const logicHeads = (rows: StandardRow[]): StandardRow[] => /** 일위대가 고정 줄(705-1 이음 꼴) — 인력 = 노임 LB · 재료 = 자재 MT · 단가 = 지금 값(셈은 단가 책 값) */ export function fixedOf(e: M01Element): LaborFixed { + if (e.종류 === "기계") { + // 736-3 기계 = 이음 기계 EQ · 단가 = 한 시간 노 · 재 · 경(셈은 줄 단가 그대로) + return { + 이름: e.이름, + 규격: e.규격, + 단위: e.단위, + 단가: e.단가 ?? { 노무비: 0, 재료비: 0, 경비: 0 }, + 이음: { 노임: null, 자재: null, 기계: e.코드 }, + }; + } const labor = e.종류 === "인력"; return { 이름: e.이름, @@ -171,6 +183,8 @@ export function openFindPicker(opt: FindPickOptions): void { rowClass: (i) => (shown[i]?.dim ? "is-dim" : null), onRowClick: (i) => shown[i]?.go(), }); + const fail = (error: unknown): void => + showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"); const take = (f: () => void) => (): void => { close(); f(); @@ -199,6 +213,20 @@ export function openFindPicker(opt: FindPickOptions): void { }, ]; }); + /** 기계 고름 — 한 시간 값을 받아 단가로(못 셈 = 까닭 알림 · 창 그대로) */ + const hourly = async (e: M01Element): Promise => { + try { + const got = await call<{ ok: boolean; reason?: string } & Record>( + `/logic-lib/machine-hourly?key=${encodeURIComponent(e.코드)}`, + ); + if (!got.ok) throw new Error(got.reason ?? tc("Load_Failed")); + const { 노무비, 재료비, 경비, 계 } = got; + return { ...e, 값: 계, 단가: { 노무비, 재료비, 경비 } }; + } catch (error) { + fail(error); + return null; + } + }; const elementLines = (kind: M01Element["종류"], items: PickItem[]): Line[] => items.map((m) => { const e: M01Element = { @@ -223,7 +251,11 @@ export function openFindPicker(opt: FindPickOptions): void { 구분: join(kind, m.구분, put ? "" : L("M02_FindElementLater")), 출처: `M01 ${m.ref}`, dim: !put, - go: put ? take(() => put(e)) : () => showToast(L("M02_FindElementLater"), "warning"), + go: !put + ? () => showToast(L("M02_FindElementLater"), "warning") + : kind === "기계" + ? () => void hourly(e).then((x) => x && take(() => put(x))()) + : take(() => put(e)), }; }); const docLines = (rows: SangeunRow[], kind: string): Line[] => @@ -346,8 +378,6 @@ export function openFindPicker(opt: FindPickOptions): void { search.input.focus(); draw(); - const fail = (error: unknown): void => - showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"); void Promise.all([fetchLibLogics(), fetchStandard().catch(() => null)]) .then(([l, s]) => { logics = l; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts index f9c08a56e..98962e502 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts @@ -109,6 +109,7 @@ export function openLogicPicker( logic: done, ref: lib.pick, element: fixed && ((e) => fixed(fixedOf(e), e.이름)), + machine: fixed && ((e) => fixed(fixedOf(e), e.이름)), // 736-3 기계 = 이음 기계 고정 줄 material: lib.material, }); } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Pick.ts index c45c64732..776eb0c34 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Pick.ts @@ -9,8 +9,8 @@ * ========================================================================== */ import { call, type Layer } from "./M02_MasterTemplete_Api_Fetch"; -import { openFindPicker } from "./M02_MasterTemplete_Find_Pick"; -import type { LaborRef } from "./M02_MasterTemplete_Labor_Calc"; +import { fixedOf, openFindPicker } from "./M02_MasterTemplete_Find_Pick"; +import type { LaborFixed, LaborRef } from "./M02_MasterTemplete_Labor_Calc"; export const SANGEUN_KIND = "sangeun"; /** 층 차례 — 위에서 아래 */ @@ -73,7 +73,8 @@ export async function listLayerDocs( export type SangeunLine = | { 종류: "로직"; 로직: string; 입력값: Record; 수량: number } | { 참조: LaborRef; 수량: number } - | { 종류: "인력" | "재료"; 요소: string; 이름: string | null; 단위: string | null; 수량: number }; + | { 종류: "인력" | "재료"; 요소: string; 이름: string | null; 단위: string | null; 수량: number } + | { 고정: LaborFixed; 이름: string; 단위: string | null; 수량: number }; /** 단가산출 [항목 추가] — `scope` = 그 SK 문서 층(고를 수 있는 서고 = 위 층만) */ export function openSangeunLinePicker( @@ -87,5 +88,7 @@ export function openSangeunLinePicker( element: (e) => e.종류 !== "기계" && done({ 종류: e.종류, 요소: e.코드, 이름: e.이름, 단위: e.단위, 수량: 1 }), + // 736-3 기계 = 고정 줄(이음 기계 EQ · 단가 = 한 시간 노 · 재 · 경) · 수량 1 + machine: (e) => done({ 고정: fixedOf(e), 이름: e.이름, 단위: e.단위, 수량: 1 }), }); } diff --git a/resources/tester/test_721_find_pick.py b/resources/tester/test_721_find_pick.py index 4f8b5133a..b81649914 100644 --- a/resources/tester/test_721_find_pick.py +++ b/resources/tester/test_721_find_pick.py @@ -63,7 +63,7 @@ def test_721_2_부르는_곳_셋만_새_창(): def test_721_2_고정_줄_꼴(): find = _text(M02 / "M02_MasterTemplete_Find_Pick.ts") assert "이음: { 노임: labor ? e.코드 : null, 자재: labor ? null : e.코드, 기계: null }" in find - assert "go: put ? take(() => put(e))" in find + assert "take(() => put(e))" in find and "!put" in find assert "dim: !put," in find # 받는 쪽 없으면 회색 막힘 @@ -151,3 +151,42 @@ def test_736_2_빠진_길_둘_신규_자재_기계_탭(): assert '"machine"' in lost and "pickMachine" not in lost unlinked = _text(M02 / "M02_MasterTemplete_Labor_Unlinked.ts") assert 'machine: (e) => done("기계", e.코드)' in unlinked and "pickMachine" not in unlinked + + +def test_736_3_기계_고정_줄_금액(): + """736-3 — [항목 추가] 기계(EQ) = 고정 줄(이음 기계 · 단가 = 한 시간 노 · 재 · 경) — 단가산출 · 일위대가가 같은 금액.""" + from M02_MasterTemplete import M02_Machine_Hourly as machine + + EQ = "EQ000019" # 굴착기(무한궤도) 0.6㎥ + got = machine.hourly(EQ) + assert got["ok"] and got["계"] > 0 + assert got["계"] == pytest.approx(got["노무비"] + got["재료비"] + got["경비"]) + assert not machine.hourly(LB)["ok"] and not machine.hourly("EQ999999")["ok"] + fx = { + "이름": got["이름"], + "규격": got["규격"], + "단위": "시간", + "단가": {k: got[k] for k in ("노무비", "재료비", "경비")}, + "이음": {"노임": None, "자재": None, "기계": EQ}, + } + sk = calc.calc_doc( + { + "키": "SK-000001", + "이름": "시험", + "단위": "시간", + "바탕": None, + "더한줄": [{"고정": fx, "이름": got["이름"], "단위": "시간", "수량": 1}], + } + )["1단위"] + doc = labor.clean({"담은로직": [{"로직": "", "고정": fx, "수량": {"손": 1}}]}) + assert sk["계"] == pytest.approx(got["계"]) + assert labor.calc(doc, {})["반영합"]["계"] == pytest.approx(got["계"]) + find = _text(M02 / "M02_MasterTemplete_Find_Pick.ts") + assert ( + "/logic-lib/machine-hourly?key=" in find + and "이음: { 노임: null, 자재: null, 기계: e.코드 }" in find + ) + assert "machine: fixed &&" in _text(M02 / "M02_MasterTemplete_Labor_Pick.ts") + assert "machine: (e) => done({ 고정: fixedOf(e)" in _text( + M02 / "M02_MasterTemplete_Sangeun_Pick.ts" + ) From afb971042380d11a9f573c76876a20cfce65438d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 2 Nov 2026 18:56:36 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat(741-1):=20=EB=B6=88=EB=9F=AC=EC=98=A4?= =?UTF-8?q?=EA=B8=B0=20=EC=97=91=EC=85=80=20=EB=B3=B4=EA=B8=B0=20=EC=97=B4?= =?UTF-8?q?=20=ED=8F=AD=20=E2=80=94=20xlsx=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=97=B4=20=ED=8F=AD=20=C2=B7=20=ED=8F=AD=20=EC=97=86=EB=8A=94?= =?UTF-8?q?=20=EC=97=B4=EC=9D=80=20=EA=B8=80=20=EA=B8=B8=EC=9D=B4=EB=A1=9C?= =?UTF-8?q?=20=C2=B7=20=EB=A8=B8=EB=A6=AC=20=EA=B2=BD=EA=B3=84=20=EB=81=8C?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=EB=91=90=20=EB=B2=88=20=EB=88=8C=EB=9F=AC?= =?UTF-8?q?=20=EB=A7=9E=EC=B6=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버: xlsx 시트 XML 열 폭 · 숨김을 보냄(엑셀 보기 격자 `열`) - 화면: 폭 없는 열은 글 길이로 처음 폭(최소 6 · 최대 60 · 여러 열 병합 제목 칸은 뺌) - 읽기 전용 격자도 열 폭 끌기 · 경계 두 번 누름 맞춤(보기만 · 저장 · 되돌리기 · onChange 없음) - 열 경계 오른쪽 반에서도 경계로 읽음 · 단가 카드 미리보기는 사본으로 보임 - 시험: test_741_sheet_widths · test_stage2_autofit(esbuild 묶어 node) Co-Authored-By: Claude Sonnet 5.5 --- A00_Common/spreadsheet/spreadsheet.ts | 6 +-- A00_Common/spreadsheet/spreadsheet_autofit.ts | 45 ++++++++++++----- A00_Common/spreadsheet/spreadsheet_grid.ts | 6 ++- A00_Common/spreadsheet/spreadsheet_mouse.ts | 4 +- M02_MasterTemplete/M02_Hopyo_Grid.py | 50 +++++++++++++++++-- .../M02_MasterTemplete_Hopyo_Sheet.ts | 2 + .../M02_MasterTemplete_ItemCards.ts | 5 +- .../tester/spreadsheet/test_stage2_autofit.ts | 21 +++++++- resources/tester/test_741_sheet_widths.py | 49 ++++++++++++++++++ 9 files changed, 164 insertions(+), 24 deletions(-) create mode 100644 resources/tester/test_741_sheet_widths.py diff --git a/A00_Common/spreadsheet/spreadsheet.ts b/A00_Common/spreadsheet/spreadsheet.ts index ef629b80f..4ae39fd85 100644 --- a/A00_Common/spreadsheet/spreadsheet.ts +++ b/A00_Common/spreadsheet/spreadsheet.ts @@ -225,10 +225,10 @@ export function createSpreadsheet( } function dispatch(command: Command): void { - if (readOnly) return; + if (readOnly && command.종류 !== "열폭") return; // 읽기 전용도 열 폭은 바꿈(보기만 · 저장 · 되돌리기 없음) const eff = apply(command); if (!eff) return; - ctx.history.push(command, eff.undo); + if (!readOnly) ctx.history.push(command, eff.undo); after(command, eff); } @@ -252,7 +252,7 @@ export function createSpreadsheet( else grid.render(); } select(ctx.selection, false, true); // 명령이 바꾼 뒤 — 알약 점 등 문서 종속 부품도 다시(81-2) - opts.onChange?.(structuredClone(ctx.book)); + if (!readOnly) opts.onChange?.(structuredClone(ctx.book)); } /** 칸 명령이 건드린 칸(서식만 바뀐 칸도 다시 그리게) */ diff --git a/A00_Common/spreadsheet/spreadsheet_autofit.ts b/A00_Common/spreadsheet/spreadsheet_autofit.ts index aea51e494..fd65722b5 100644 --- a/A00_Common/spreadsheet/spreadsheet_autofit.ts +++ b/A00_Common/spreadsheet/spreadsheet_autofit.ts @@ -13,7 +13,8 @@ * · 확대 · 축소 — 값 자체를 저장하지 않는 화면 전용 CSS 배율(C 격자 뿌리에 transform) · 계약 밖. * ========================================================================== */ -import type { Workbook } from "./spreadsheet_types"; +import { colIndex, colName, parseRange } from "./spreadsheet_address"; +import type { Sheet, Workbook } from "./spreadsheet_types"; import type { SpreadsheetContext } from "./spreadsheet_view_types"; export type TextMeasurer = (text: string) => number; @@ -30,13 +31,27 @@ const defaultMeasure: TextMeasurer = (text) => { const colFromKey = (key: string): number => { const m = /^([A-Z]+)\d+$/.exec(key); - if (!m) return -1; - let col = 0; - for (const ch of m[1]) col = col * 26 + (ch.charCodeAt(0) - 64); - return col - 1; + return m ? colIndex(m[1]) : -1; }; -/** 그 열에 든 칸 글자(식 칸은 식 글로 어림 — 계산값은 여기서 모름) 중 가장 넓은 것 + 여백. */ +/** 열마다 가장 넓은 칸 글자 폭(식 칸은 식 글로 어림 — 계산값은 여기서 모름) · 여러 열 병합 칸은 뺌(제목 줄). */ +function widestByColumn(sheet: Sheet, measure: TextMeasurer): Map { + const wide = new Set(); + for (const text of sheet.병합 ?? []) { + const r = parseRange(text); + if (r && r.c1 > r.c0) wide.add(`${colName(r.c0)}${r.r0 + 1}`); + } + const out = new Map(); + for (const [key, cell] of Object.entries(sheet.칸)) { + const text = cell.식 !== undefined ? cell.식 : cell.값 !== undefined ? String(cell.값) : ""; + const col = colFromKey(key); + if (!text || col < 0 || wide.has(key)) continue; + out.set(col, Math.max(out.get(col) ?? 0, measure(text))); + } + return out; +} + +/** 그 열에 든 칸 글자 중 가장 넓은 것 + 여백. */ export function estimateColumnWidth( book: Workbook, sheetId: string, @@ -45,13 +60,19 @@ export function estimateColumnWidth( ): number { const sheet = book.시트.find((s) => s.id === sheetId); if (!sheet) return MIN_WIDTH; - let widest = 0; - for (const [key, cell] of Object.entries(sheet.칸)) { - if (colFromKey(key) !== col) continue; - const text = cell.식 !== undefined ? cell.식 : cell.값 !== undefined ? String(cell.값) : ""; - if (text) widest = Math.max(widest, measure(text)); + return Math.max(MIN_WIDTH, Math.ceil((widestByColumn(sheet, measure).get(col) ?? 0) + PADDING)); +} + +/** 폭 정보 없는 열(엑셀 폭을 못 읽은 파일)에 글 길이로 처음 폭을 채움 — 최소 · 최대 안(읽기 전용 보기). */ +export function fillMissingWidths(book: Workbook, min = 6, max = 60): void { + for (const sheet of book.시트) { + for (const [col, widest] of widestByColumn(sheet, defaultMeasure)) { + const letter = colName(col); + if (sheet.열?.[letter]?.폭 !== undefined) continue; + const 폭 = Math.min(max, Math.max(min, Math.ceil(widest + PADDING))); + sheet.열 = { ...sheet.열, [letter]: { ...sheet.열?.[letter], 폭 } }; + } } - return Math.max(MIN_WIDTH, Math.ceil(widest + PADDING)); } /** 폭을 자동 맞춤 값으로 바꿈(열넓이 명령 하나). */ diff --git a/A00_Common/spreadsheet/spreadsheet_grid.ts b/A00_Common/spreadsheet/spreadsheet_grid.ts index 416f5dd62..08aa55635 100644 --- a/A00_Common/spreadsheet/spreadsheet_grid.ts +++ b/A00_Common/spreadsheet/spreadsheet_grid.ts @@ -572,8 +572,10 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { const contentY = y < headRowH() ? -1 : y - headRowH() + (y - headRowH() > frozenH ? paneBR.scrollTop : 0); if (y < headRowH()) { - const c = colGeo.indexAt(contentX); - const edge = Math.abs(colGeo.offset(c + 1) - contentX) <= RESIZE_HOTZONE; + const at = colGeo.indexAt(contentX); + const after = at > 0 && contentX - colGeo.offset(at) <= RESIZE_HOTZONE; // 경계 오른쪽 반 = 앞 열 경계 + const c = after ? at - 1 : at; + const edge = after || Math.abs(colGeo.offset(c + 1) - contentX) <= RESIZE_HOTZONE; return { kind: edge ? "colEdge" : "colHead", r: 0, c }; } if (x < headColW()) { diff --git a/A00_Common/spreadsheet/spreadsheet_mouse.ts b/A00_Common/spreadsheet/spreadsheet_mouse.ts index 9bff078dd..86e77b8bf 100644 --- a/A00_Common/spreadsheet/spreadsheet_mouse.ts +++ b/A00_Common/spreadsheet/spreadsheet_mouse.ts @@ -233,9 +233,9 @@ export function attachMouse( } function onDbl(e: MouseEvent): void { - if (e.target === ed.ta || ctx.readOnly) return; + if (e.target === ed.ta) return; const hit = ctx.grid.hitTest(e.clientX, e.clientY); - if (!hit) return; + if (!hit || (ctx.readOnly && hit.kind !== "colEdge")) return; // 읽기 전용 = 열 경계 맞춤만 if (hit.kind === "fillHandle") return fillDown(); if (hit.kind === "colEdge") { // 고른 열 전체의 경계면 고른 열 모두(엑셀) diff --git a/M02_MasterTemplete/M02_Hopyo_Grid.py b/M02_MasterTemplete/M02_Hopyo_Grid.py index b41f477f2..4c63b1d4a 100644 --- a/M02_MasterTemplete/M02_Hopyo_Grid.py +++ b/M02_MasterTemplete/M02_Hopyo_Grid.py @@ -6,16 +6,19 @@ A00 산출근거 스프레드시트 Workbook(`A00_Common/spreadsheet/spreadsheet xls = xlrd `formatting_info` — 값 · 병합 · 열 폭(÷256 글자) · 행 높이(÷20 pt · 시트 기본과 다른 것만) · 서식 표(글꼴 · 굵게 · 맞춤 · 줄바꿈 · 테두리 · 채움 · 숫자 형식) · 틀고정(파일 값 · 없으면 머리 4행). -xlsx = 값만 · 넓은 시트는 머리 글(앞 4 줄) 마지막 열까지 · STmate 숨은 칸 글(「_x0005_`BDCOD…」) 뺌. +xlsx = 값 · 열 폭(``) · 넓은 시트는 머리 글(앞 4 줄) 마지막 열까지 · +STmate 숨은 칸 글(「_x0005_`BDCOD…」) 뺌. 결과 `{책, 호표: [{번, 이름, 행}], 단산: [{번, 이름, 행}]}` — 행 = 엑셀 1부터 [시작, 끝]. """ from __future__ import annotations import io +import zipfile from typing import Any from openpyxl.utils import get_column_letter +from openpyxl.xml.functions import fromstring from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis from M02_MasterTemplete import M02_Hopyo_Excel as hopyo_excel @@ -178,9 +181,42 @@ def _xls(data: bytes) -> dict[str, Any]: } +def _xlsx_widths(data: bytes) -> dict[str, dict[str, dict[str, Any]]]: + """시트 이름 → 열 글자 → `{폭, 숨김}` — 시트 XML `` 를 읽음(read_only 는 폭을 안 줌).""" + out: dict[str, dict[str, dict[str, Any]]] = {} + try: + with zipfile.ZipFile(io.BytesIO(data)) as zf: + tag = lambda node: node.tag.rsplit("}", 1)[-1] # noqa: E731 + rels = { + r.get("Id"): r.get("Target", "") + for r in fromstring(zf.read("xl/_rels/workbook.xml.rels")) + } + for sheet in fromstring(zf.read("xl/workbook.xml")).iter(): + if tag(sheet) != "sheet": + continue + rid = next((v for k, v in sheet.attrib.items() if k.endswith("}id")), None) + path = (rels.get(rid) or "").lstrip("/") + path = path if path.startswith("xl/") else f"xl/{path}" + cols: dict[str, dict[str, Any]] = {} + for col in fromstring(zf.read(path)).iter(): + if tag(col) != "col": + continue + first, last = int(col.get("min", 0)), min(int(col.get("max", 0)), 200) + one = {"폭": round(float(col.get("width", 0)), 2)} + if col.get("hidden") in ("1", "true"): + one["숨김"] = True + for c in range(first, last + 1): + cols[get_column_letter(c)] = one + out[sheet.get("name", "").strip()] = cols + except (KeyError, ValueError, zipfile.BadZipFile): + pass # 폭을 못 읽으면 폭 없음 — 화면이 글 길이로 맞춤 + return out + + def _xlsx(name: str, data: bytes) -> dict[str, Any]: - # ponytail: xlsx 는 값만(병합 · 폭 · 서식은 openpyxl 비 read_only 로 무거움) — 필요하면 그때 + # ponytail: xlsx 는 값 · 열 폭만(병합 · 서식은 openpyxl 비 read_only 로 무거움) — 필요하면 그때 found = hopyo_excel._sheets(name, data) + widths = _xlsx_widths(data) sheets = [] for at, title in enumerate((n for n in SHEETS if n in found), 1): rows = found[title] @@ -196,7 +232,15 @@ def _xlsx(name: str, data: bytes) -> dict[str, Any]: for c, v in enumerate(row[:width]) if v not in (None, "") and not str(v).startswith("_x000") # STmate 숨은 칸 글 } - sheets.append({"id": f"s{at}", "이름": title, "칸": cells, "틀고정": {"행": HEAD_ROWS}}) + sheets.append( + { + "id": f"s{at}", + "이름": title, + "칸": cells, + "열": widths.get(title, {}), + "틀고정": {"행": HEAD_ROWS}, + } + ) return {"서식": [{}], "시트": sheets} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts index f8f5b65c5..e585953e5 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts @@ -336,8 +336,10 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { async function mountSheet(grid: HopyoGrid): Promise { const { createSpreadsheet } = await import("../A00_Common/spreadsheet/spreadsheet"); + const { fillMissingWidths } = await import("../A00_Common/spreadsheet/spreadsheet_autofit"); if (closed) return; const book = structuredClone(grid.책); + fillMissingWidths(book); // 엑셀 폭이 없는 열 = 글 길이로 처음 폭 const first = book.시트.find((s) => s.이름 === ILWI) ?? book.시트[0]; if (!first) return void left.replaceChildren(note("시트 없음")); ilwi = first.id; diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts index 8c41aceb2..d84d38233 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts @@ -449,7 +449,10 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { const { createSpreadsheet } = await loadSpreadsheet(); if (mine !== seq || !c.doc) return; if (opts.sheetEdit?.can()) return await paintEditable(c, mine, createSpreadsheet); - c.handle = createSpreadsheet(c.host, c.doc, { readOnly: true, tabs: false }); + c.handle = createSpreadsheet(c.host, structuredClone(c.doc), { + readOnly: true, + tabs: false, + }); // 열 폭을 끌어 바꿔도 원본 안 건드림 } catch (error) { if (mine === seq) c.shell.fail(fill(TEXT.noSheet, why(error)), reload); } diff --git a/resources/tester/spreadsheet/test_stage2_autofit.ts b/resources/tester/spreadsheet/test_stage2_autofit.ts index ec62db198..4482db19f 100644 --- a/resources/tester/spreadsheet/test_stage2_autofit.ts +++ b/resources/tester/spreadsheet/test_stage2_autofit.ts @@ -1,10 +1,11 @@ /* ============================================================================= - * test_stage2_autofit.ts — spreadsheet_autofit 시험. 돌리기: node --experimental-strip-types <이 파일> + * test_stage2_autofit.ts — spreadsheet_autofit 시험. 돌리기: esbuild --bundle --platform=node <이 파일> → node (주소 import 가 확장자 없음) * ========================================================================== */ import { autofitColumn, estimateColumnWidth, + fillMissingWidths, } from "../../../A00_Common/spreadsheet/spreadsheet_autofit.ts"; import { assertEqual, makeBook, makeCtx, makeSheet, v } from "./test_stage2_helpers.ts"; @@ -28,5 +29,23 @@ assertEqual(sheet.열?.A.폭, widthA, "autofitColumn 이 열폭 명령으로 반 const fixedWidth = estimateColumnWidth(book, "s1", 0, () => 7); assertEqual(fixedWidth, 9, "measure 를 넘기면 그걸로 어림(7 + 여백 2)"); +// 741 — 폭 없는 열만 글 길이로 채움(최소 · 최대) · 있는 폭 · 여러 열 병합(제목) 칸은 그대로 · 뺌 +const longText = "가".repeat(80); +const sheet741 = makeSheet("s2", { + A1: v("제목 한 줄이 매우 길어도 병합이라 뺌 ".repeat(5)), + A2: v("짧음"), + B1: v(longText), + C1: v("x"), + D1: v("이미 있음"), +}); +sheet741.병합 = ["A1:D1"]; +sheet741.열 = { D: { 폭: 33 } }; +const book741 = makeBook([sheet741]); +fillMissingWidths(book741); +assertEqual(sheet741.열.D.폭, 33, "있는 폭은 그대로"); +assertEqual(sheet741.열.B.폭, 60, "너무 긴 글은 최대 60"); +assertEqual(sheet741.열.C.폭, 6, "짧은 글은 최소 6"); +assertEqual(sheet741.열.A.폭 < 20, true, "병합 제목 칸은 폭에서 뺌"); + if (process.exitCode) process.exit(process.exitCode); console.log("spreadsheet_autofit 시험 끝"); diff --git a/resources/tester/test_741_sheet_widths.py b/resources/tester/test_741_sheet_widths.py new file mode 100644 index 000000000..6c25f8d35 --- /dev/null +++ b/resources/tester/test_741_sheet_widths.py @@ -0,0 +1,49 @@ +"""741-1 지킴 — 일위대가 엑셀 보기 열 폭: xlsx 도 파일 열 폭을 서버가 보냄 · 열 끌기 · 두 번 눌러 +맞춤은 읽기 전용에서도 열림(화면 수치 판정은 tmp 일회 검증).""" + +import io +from pathlib import Path + +import openpyxl + +from M02_MasterTemplete import M02_Hopyo_Grid as grid + +ROOT = Path(__file__).parents[2] +SS = ROOT / "A00_Common/spreadsheet" + + +def _xlsx(widths: dict[str, float]) -> bytes: + wb = openpyxl.Workbook() + ws = wb.active + ws.title = "일위대가" + ws["A1"] = "x" + for letter, width in widths.items(): + ws.column_dimensions[letter].width = width + ws.column_dimensions["E"].hidden = True + out = io.BytesIO() + wb.save(out) + return out.getvalue() + + +def test_xlsx_widths_read_from_cols(): + got = grid._xlsx_widths(_xlsx({"A": 30, "C": 12.5}))["일위대가"] + assert got["A"] == {"폭": 30.0} and got["C"] == {"폭": 12.5} + assert got["E"].get("숨김") is True + + +def test_xlsx_without_widths_is_empty_and_garbage_is_safe(): + assert grid._xlsx_widths(_xlsx({}))["일위대가"].keys() <= {"E"} + assert grid._xlsx_widths(b"not a zip") == {} + + +def test_xlsx_sheet_carries_widths(): + data = _xlsx({"A": 30}) + sheets = grid._xlsx("a.xlsx", data)["시트"] + assert sheets and sheets[0]["열"]["A"]["폭"] == 30.0 + + +def test_readonly_still_resizes_columns(): + spread = (SS / "spreadsheet.ts").read_text(encoding="utf-8") + assert 'readOnly && command.종류 !== "열폭"' in spread + mouse = (SS / "spreadsheet_mouse.ts").read_text(encoding="utf-8") + assert 'ctx.readOnly && hit.kind !== "colEdge"' in mouse