From a961e9cb693918964e94ebcd8770c63cb023e3c8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 14:43:23 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat(716-1=C2=B7716-2):=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=EB=AC=BC=20=EC=98=B5=EC=85=98=20=EC=A0=95=EC=9D=98=20?= =?UTF-8?q?=EC=9E=85=EA=B5=AC=20M01=20=C2=B7=20=EB=A9=94=EB=89=B4=20?= =?UTF-8?q?=ED=91=9C=20=EB=91=98=20=C2=B7=20M01=20open=20=EC=9D=B8?= =?UTF-8?q?=EC=9E=90=20(=EC=A4=91=EA=B0=84=20=E2=80=94=20=EA=B5=AC?= =?UTF-8?q?=EB=B6=84=EC=84=A0=20=EB=B6=80=ED=92=88=20=C2=B7=20=EC=8B=9C?= =?UTF-8?q?=ED=97=98=20=EC=A0=95=EB=A6=AC=20=EC=A0=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M01 왼쪽 단가산출 로직 칸 아래 구조물 옵션 정의 칸 · 본 칸 셋째 모드(저장 안 한 고침 물음) - M02 옛 open=옵션 주소 = 시스템 관리자면 M01 로 돌려보냄 - settingLinks = 대시보드 칸별 줄(group · ?open=) · pageLinks = ☰ 페이지 둘 - 새 글 키 덧만: B01_Dashboard_CardLogic · CardStructOptionDef · CardPrecombo Co-Authored-By: Claude Sonnet 5.5 --- A00_Common/m_settings_nav.ts | 57 +++++++++++++----- .../B01_Dashboard_UI_View_SysDefaults.ts | 16 ++--- M01_MasterData/M01_MasterData_UI_Page.ts | 59 +++++++++++++++++-- M01_MasterData/M01_MasterData_UI_Side.ts | 48 +++++++++++++-- .../M02_MasterTemplete_Layer.ts | 8 +++ .../M02_MasterTemplete_UI_Page.ts | 5 +- .../StructOptions/M02_SO_Style.css | 8 +++ ui_template/ui_template_locale_m1.ts | 2 + ui_template/ui_template_locale_m2.ts | 1 + 9 files changed, 166 insertions(+), 38 deletions(-) diff --git a/A00_Common/m_settings_nav.ts b/A00_Common/m_settings_nav.ts index 73b09f8c7..817dcafc0 100644 --- a/A00_Common/m_settings_nav.ts +++ b/A00_Common/m_settings_nav.ts @@ -1,7 +1,7 @@ /* ============================================================================= * m_settings_nav.ts - * 시스템 설정 바로가기(334 · 요구 225-2) — 대시보드 「시스템 설정」 카드 · M01 · M02 ☰ 가 같이 쓰는 - * 역할 규칙 한 벌(`settingLinks`) · ☰ 진행 단계 자리에 🏠 대시보드 + 카드(`mountSettingsDrawer`). + * 시스템 설정 바로가기(334 · 요구 225-2 · 716-2) — 대시보드 설계 기본값 줄(`settingLinks` · 칸마다 한 줄 · + * `?open=`) / M01 · M02 ☰ 메뉴(`pageLinks` · 페이지 둘 · `mountSettingsDrawer`). * ========================================================================== */ import { ROUTES, type RoutePath } from "@config/config_frontend"; @@ -16,52 +16,63 @@ interface SettingUser { company_id?: number | null; } -/** key = M01 은 「m01」 · 계산 자리 창은 「calcSites」(대시보드 카드만 · 504-2) · M02 는 진입 인자 `open` */ +/** key = 페이지 열쇠(☰ · 「m01」 「m02」) 또는 칸 열쇠(대시보드 줄 · 계산 자리 「calcSites」 = 창 504-2) · + * group = 대시보드 설계 기본값 묶음 제목 */ export interface SettingLink { key: string; title: string; desc: string; route: RoutePath; query?: Record; + group?: string; } function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } -const ICONS: Record = { m01: "🗄️", 구조물조합: "🧱", 옵션: "🎛️", 도면: "📑" }; +const ICONS: Record = { m01: "🗄️", m02: "🧱" }; -/** 시스템 설정 — 역할로 카드 숨김 · M02 카드는 `?open=&root=` 로 진입(27-1d) */ +/** 설계 기본값 줄(대시보드) — 칸마다 한 줄 · 역할로 숨김 · M01 줄 `?open=`(칸 고름) · M02 줄 `?open=&root=`(27-1d). + * 칸을 더할 때는 아래 표에 한 줄(m01 / m02 호출)만 더함. */ export function settingLinks(user: SettingUser, companyId: number | string | null): SettingLink[] { // 시스템 관리자는 고른 회사를 해시에 실음(29-1) const company: Record = user.role === "SYSTEM_ADMIN" && companyId ? { company: String(companyId) } : {}; + const m01 = (key: string, title: string, desc: string, open?: string): SettingLink => ({ + key, + title, + desc, + route: ROUTES.M01_MASTER_DATA, + query: open ? { open } : undefined, + group: "마스터 데이터", + }); const m02 = (title: string, open: string, root: string, desc: string): SettingLink => ({ key: open, title, desc, route: ROUTES.M02_MASTER_TEMPLATE, query: { open, root, ...company }, + group: "마스터 템플릿", }); // 구조물 조합 기본 층 = 개인(123-1 · 누구든) · 개인 층 없음(회사 없음) = 아래 역할 규칙 const comboRoot = (fallback: string): string => (user.company_id ? "personal" : fallback); if (user.role === "SYSTEM_ADMIN") { const desc = L("B01_Dashboard_PermMasterWrite"); + const read = L("B01_Dashboard_PermSourceRead"); return [ - { - key: "m01", - title: L("B01_Dashboard_MasterElements"), - desc: L("B01_Dashboard_PermSourceRead"), - route: ROUTES.M01_MASTER_DATA, - }, + m01("m01", L("M01_Title"), read), + m01("로직", L("B01_Dashboard_CardLogic"), read, "로직"), + m01("옵션", L("B01_Dashboard_CardStructOptionDef"), desc, "옵션"), + m02(L("B01_Dashboard_CardPrecombo"), "일위대가조합", comboRoot("master"), desc), m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", comboRoot("master"), desc), - m02(L("B01_Dashboard_CardStructOption"), "옵션", "master", desc), m02(L("B01_Dashboard_CardDrawing"), "도면", "master", desc), { key: "calcSites", title: L("B01_CalcSites_Title"), desc: L("B01_CalcSites_Desc"), route: ROUTES.B01_ACCOUNT, + group: "계산", }, ]; } @@ -76,14 +87,30 @@ export function settingLinks(user: SettingUser, companyId: number | string | nul return [m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", comboRoot(root), desc)]; } -/** ☰ 진행 단계 자리 — 🏠 대시보드 + 카드 · 지금 칸 강조 · 같은 길(M02 → M02)은 머리 가드가 안 물어 여기서 먼저 */ +/** ☰ 메뉴 — 페이지 둘만(M01 = 시스템 관리자 · M02 = 누구나) · 열쇠 「m01」 「m02」 */ +export function pageLinks(user: SettingUser, companyId: number | string | null): SettingLink[] { + const combo = settingLinks(user, companyId).find((link) => link.key === "구조물조합"); + const links: SettingLink[] = []; + if (user.role === "SYSTEM_ADMIN") + links.push({ + key: "m01", + title: L("B01_Dashboard_MasterElements"), + desc: "", + route: ROUTES.M01_MASTER_DATA, + }); + if (combo) + links.push({ ...combo, key: "m02", title: L("M02_Title"), desc: "", group: undefined }); + return links; +} + +/** ☰ 진행 단계 자리 — 🏠 대시보드 + 페이지 둘 · 지금 페이지 강조(`activeKey` = 「m01」 「m02」) · + * 같은 길(M02 → M02)은 머리 가드가 안 물어 여기서 먼저 */ export function mountSettingsDrawer( user: SettingUser, activeKey: string, companyId: number | string | null, ): void { - // 계산 자리 = 대시보드 창(504-2) — 페이지가 아니라 ☰ 에 안 둠 - const links = settingLinks(user, companyId).filter((link) => link.key !== "calcSites"); + const links = pageLinks(user, companyId); const bar = createStepBar( links.map((link) => link.title), links.findIndex((link) => link.key === activeKey), diff --git a/B01_Dashboard/B01_Dashboard_UI_View_SysDefaults.ts b/B01_Dashboard/B01_Dashboard_UI_View_SysDefaults.ts index df9a8a70e..72060c3df 100644 --- a/B01_Dashboard/B01_Dashboard_UI_View_SysDefaults.ts +++ b/B01_Dashboard/B01_Dashboard_UI_View_SysDefaults.ts @@ -1,7 +1,8 @@ /* ============================================================================= * B01_Dashboard_UI_View_SysDefaults.ts - * 시스템 관리 › 설계 기본값(549-2) — 마스터 데이터 · 구조물 조합 · 구조물 옵션 · 도면 양식 · 계산 자리. - * 카드 규칙은 `settingLinks` 한 벌(334) — 여기는 모양만(묶음 제목 + 둥근 상자 안 줄) · 계산 자리 = 창(504-2). + * 시스템 관리 › 설계 기본값(549-2 · 716-2) — 마스터 데이터(마스터 요소 · 단가산출 로직 · 구조물 옵션 정의) · + * 마스터 템플릿(일위대가 · 구조물 조합 · 도면 양식) · 계산 자리. 칸마다 한 줄(누르면 그 칸을 고른 채 열림 · `?open=`). + * 줄 · 묶음 규칙은 `settingLinks` 한 벌(334) — 여기는 모양만(묶음 제목 + 둥근 상자 안 줄) · 계산 자리 = 창(504-2). * ========================================================================== */ import { createButton } from "@ui/ui_template_elements"; @@ -12,22 +13,13 @@ import { pageFrame, settingCard, settingRow } from "./B01_Dashboard_UI_PageKit"; import { group } from "./B01_Dashboard_UI_View_Parts"; import type { ViewCtx } from "./B01_Dashboard_UI_Views"; -/** 카드 열쇠 → 묶음 제목 — 모르는 열쇠는 「그 밖」 */ -const GROUP_OF: Record = { - m01: "마스터 데이터", - 구조물조합: "구조물 · 도면", - 옵션: "구조물 · 도면", - 도면: "구조물 · 도면", - calcSites: "계산", -}; - const open = (link: SettingLink): void => link.key === "calcSites" ? void openCalcSitesDialog() : navigateTo(link.route, link.query); export function mountSysDefaults(host: HTMLElement, ctx: ViewCtx): void { const groups = new Map(); for (const link of settingLinks(ctx.me, ctx.companyId)) { - const title = GROUP_OF[link.key] ?? "그 밖"; + const title = link.group ?? "그 밖"; const openBtn = createButton({ label: "열기", variant: "ghost", onClick: () => open(link) }); groups.set(title, [...(groups.get(title) ?? []), settingRow(link.title, link.desc, openBtn)]); } diff --git a/M01_MasterData/M01_MasterData_UI_Page.ts b/M01_MasterData/M01_MasterData_UI_Page.ts index c40020fd3..f4a2ed43b 100644 --- a/M01_MasterData/M01_MasterData_UI_Page.ts +++ b/M01_MasterData/M01_MasterData_UI_Page.ts @@ -1,8 +1,8 @@ /* ============================================================================= * M01_MasterData_UI_Page.ts * 마스터 요소 화면 — 좌측 도킹 패널(컨테이너: 인력·재료·기계·소요량·계수·환율·요율·단가산출 로직) - * · 일위대가 조합은 M02 마스터 템플릿(PLAN 27-2b) - * / 우측 요소 표 · 표 목록 · 로직 편집 + * · 일위대가 조합은 M02 마스터 템플릿(PLAN 27-2b) · 구조물 옵션 정의(716-1 · 화면은 M02 `StructOptions/`) + * / 우측 요소 표 · 표 목록 · 로직 편집 · 옵션 정의 · 주소 `?open=로직|옵션` = 그 칸을 고른 채 열림(716-2) * * 시스템 관리자만. 고친 것은 초안(sessionStorage)에 쌓이고 [저장] 한 번에 서버로 — * 자동저장 없음. @@ -26,7 +26,7 @@ import { t as L } from "@ui/ui_template_locale"; import { createWorkflowOverlays } from "@ui/ui_template_overlay"; import { ROUTES } from "@config/config_frontend"; import { mountSettingsDrawer } from "../A00_Common/m_settings_nav"; -import { navigateTo } from "../A00_Common/router"; +import { currentQuery, navigateTo, setLeaveGuard } from "../A00_Common/router"; import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; import { saveFiles, type FileInfo } from "./M01_MasterData_Api_Fetch"; import { discard, isStale, onDraftChange, payload, totalCount } from "./M01_MasterData_Draft"; @@ -89,6 +89,7 @@ function buildPage(): HTMLElement { children: [headBar.root, buildHeadFilters([searchField.root])], }); const logicHost = el("div", { className: "m01-master__logic", attrs: { hidden: "" } }); + const optionsHost = el("div", { className: "m02-sod__host", attrs: { hidden: "" } }); const elementView = el("div", { className: "m01-master__elements", children: [ @@ -112,12 +113,50 @@ function buildPage(): HTMLElement { onDraftChange(refreshBar); refreshBar(); - const showMode = (mode: "elements" | "logic"): void => { + const showMode = (mode: "elements" | "logic" | "options"): void => { logicHost.hidden = mode !== "logic"; elementView.hidden = mode !== "elements"; + optionsHost.hidden = mode !== "options"; + if (mode !== "options") side.setOptionsMark(false); + }; + + /* --- 구조물 옵션 정의(716-1) — 저장 안 한 고침은 칸을 바꿀 때 · 페이지를 떠날 때 물음 --- */ + let optionsDirty = false; + const leaveOptions = async (): Promise => { + if (!optionsDirty || optionsHost.hidden) return true; + if (!(await showConfirmDialog(L("M02_DiscardConfirm")))) return false; + optionsDirty = false; + return true; + }; + /** 옵션 정의에 고침이 있으면 물은 뒤에 `run` — 취소면 옵션 줄 표시를 되돌려 머묾 */ + const afterLeave = (run: () => void): boolean => { + if (!optionsDirty || optionsHost.hidden) return true; + void leaveOptions().then((ok) => { + if (ok) return run(); + side.setActive(null); + side.setOptionsMark(true); + }); + return false; + }; + const showOptions = (label: string, node: HTMLElement): ((dirty: boolean) => void) => { + dispose(); + dispose = (): void => {}; + current = null; + optionsDirty = false; + optionsHost.replaceChildren( + el("div", { + className: "m01-master__headbox", + children: [buildHeadBar({ title: label, buttons: [] }).root], + }), + node, + ); + showMode("options"); + setLeaveGuard(leaveOptions); + return (dirty) => (optionsDirty = dirty); }; const openFile = (pick: Pick): void => { + if (!afterLeave(() => openFile(pick))) return; dispose(); showMode("elements"); if (pick.view) { @@ -203,6 +242,7 @@ function buildPage(): HTMLElement { /** 「단가산출 로직」 탭으로 전환 — `key` 가 있으면 그 로직을 바로 엶. */ const openLogicTab = (key?: string): void => { if (key) setPendingLogicKey(key); + if (!afterLeave(() => openLogicTab())) return; dispose(); dispose = (): void => {}; current = null; @@ -259,7 +299,11 @@ function buildPage(): HTMLElement { setMasterJump((target) => void jump(target)); /* --- 좌측: 컨테이너 --- */ - const side = buildSide(openFile, () => openLogicTab()); + const side = buildSide(openFile, () => openLogicTab(), { + confirmLeave: leaveOptions, + show: showOptions, + onOpen: () => side.setActive(null), + }); const layout = el("div", { className: "ui-workflow-layout m01-master" }); const main = el("main", { @@ -267,7 +311,7 @@ function buildPage(): HTMLElement { children: [ el("div", { className: "m01-master__panel", - children: [elementView, logicHost], + children: [elementView, logicHost, optionsHost], }), ], }); @@ -287,5 +331,8 @@ function buildPage(): HTMLElement { ); // 다른 화면(일위대가 조합 다이어그램 등)이 로직 키를 들고 왔으면 로직 탭으로 바로 엶 if (hasPendingLogicKey()) openLogicTab(); + // 주소 `?open=` — 대시보드 설계 기본값 줄 · 옛 M02 `open=옵션` 즐겨찾기가 그 칸을 고른 채 엶 + const want = currentQuery().get("open"); + if (want === "로직" || want === "옵션") side.reveal(want); return layout; } diff --git a/M01_MasterData/M01_MasterData_UI_Side.ts b/M01_MasterData/M01_MasterData_UI_Side.ts index 0f9e752f0..1c7e90b0e 100644 --- a/M01_MasterData/M01_MasterData_UI_Side.ts +++ b/M01_MasterData/M01_MasterData_UI_Side.ts @@ -1,13 +1,18 @@ /* ============================================================================= * M01_MasterData_UI_Side.ts - * 왼쪽 패널 — 공용 접기 컨테이너(`ui-collapsible ui-sidebar-section`) 아홉 개 + * 왼쪽 패널 — 공용 접기 컨테이너(`ui-collapsible ui-sidebar-section`) * 인력 · 재료 · 기계 · 소요량 · 계수 · 환율 · 요율 · 단가산출 로직(일위대가 조합은 M02 · PLAN 27-2b) + * ─ 구분선 ─ 구조물 옵션 정의(716-1 · 화면 코드는 M02 `StructOptions/` 그대로 · 입구만 여기) * 펼치면 하위 목록(거름 · 파일) · 환율/요율은 누르면 바로 표 · 로직은 안에 로직 목록 * ========================================================================== */ import { createSelectField, el, showToast } from "@ui/ui_template_elements"; import { attachCollapsible } from "@ui/ui_template_collapsible"; import { currentLanguageIndex, t as L } from "@ui/ui_template_locale"; +import { + buildOptionsSection, + type OptionsSectionOptions, +} from "../M02_MasterTemplete/StructOptions/M02_SO_Options"; import { fetchFiles, fetchSubsAll, type FileInfo, type SubInfo } from "./M01_MasterData_Api_Fetch"; import { tx } from "./M01_MasterData_UI_Logic_Text"; import { renderTree, type MakeRow } from "./M01_MasterData_UI_Tree"; @@ -103,6 +108,10 @@ export interface SideHandle { setOnPicked: (fn: () => void) => void; /** 「전체」 + 드롭다운 거름 한 벌 — 로직 컨테이너도 씀 */ filter: (args: FilterArgs) => HTMLElement; + /** 주소 `open` — 그 컨테이너를 펼치고 고름(로직 = 로직 목록 · 옵션 = 정의 화면까지) */ + reveal: (target: "로직" | "옵션") => void; + /** 옵션 줄 고름 표시 지움(다른 화면을 열었음) · 켬(떠나기를 취소해 머묾) */ + setOptionsMark: (on: boolean) => void; } export interface FilterArgs { @@ -157,7 +166,11 @@ const LEAF: Group[] = ["환율", "요율"]; const FILTERED: Group[] = ["인력", "기계", "소요량", "계수"]; const LOGIC_ID = "로직|"; -export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): SideHandle { +export function buildSide( + onOpen: (pick: Pick) => void, + onLogic: () => void, + optionsOpt: Omit, +): SideHandle { const lists = new Map(); const bodies = new Map(); const buttons = new Map(); @@ -483,9 +496,12 @@ ${first.file}` onLogic(); }); + // 구조물 옵션 정의 — 로직 칸 아래 구분선 + 칸 하나(시스템 관리자 화면이라 고침 가능) + const options = buildOptionsSection({ ...optionsOpt, isAdmin: true }); + const optionsSec = section(options.title, options.body); const root = el("div", { className: "m01-side ui-sidebar-panel", - children: [...groups, logic], + children: [...groups, logic, el("hr", { className: "ui-sidebar-divider" }), optionsSec], }); attachCollapsible(root); void Promise.all(GROUPS.filter((g) => g !== FUEL_GROUP).map(refresh)).catch((error) => @@ -497,5 +513,29 @@ ${first.file}` // 재료는 유가 · 전력 파일까지 다(알약 → 마스터 화면 길이 파일로 찾음) · 유가 · 전력은 그 파일만 const files = (group: string): FileInfo[] => group === FUEL_GROUP ? shown(FUEL_GROUP) : (lists.get(group as Group) ?? []); - return { root, logicHost, refresh, files, setActive, setOnPicked, filter }; + const reveal = (target: "로직" | "옵션"): void => { + if (target === "옵션") { + optionsSec.classList.remove("is-collapsed"); + options.body.querySelector("button")?.click(); + return; + } + logic.classList.remove("is-collapsed"); + setActive(buttons.has(LOGIC_ID) ? LOGIC_ID : null); + onLogic(); + }; + const setOptionsMark = (on: boolean): void => { + if (!on) return options.clear(); + options.body.querySelector("button")?.classList.add("is-active"); + }; + return { + root, + logicHost, + refresh, + files, + setActive, + setOnPicked, + filter, + reveal, + setOptionsMark, + }; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Layer.ts b/M02_MasterTemplete/M02_MasterTemplete_Layer.ts index ce046f785..242e0fa69 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Layer.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Layer.ts @@ -51,6 +51,14 @@ export function entryFromHash(user?: SessionUser | null): Entry { return { root, owner, open, company: query.get("company") || null, pre }; } +/** 옛 주소 `open=옵션`(구조물 옵션 정의는 M01 로 옮김 · 716-1) — 시스템 관리자면 M01 그 칸으로 돌려보냄 · + * 돌려보냈으면 참(그 밖 역할은 읽기 화면이 없어져 구조물 조합으로 열림) */ +export function redirectOldOptions(user: SessionUser | null): boolean { + if (user?.role !== "SYSTEM_ADMIN" || currentQuery().get("open") !== "옵션") return false; + navigateTo(ROUTES.M01_MASTER_DATA, { open: "옵션" }); + return true; +} + /** 진입 해시 인자 — `navigateTo(ROUTES.M02_MASTER_TEMPLATE, entryQuery(…))` */ export const entryQuery = (entry: Entry): Record => ({ open: entry.open, diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts index 17cb6cbc6..436262468 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts @@ -31,6 +31,7 @@ import { entryQuery, layerOfRoot, ownOwner, + redirectOldOptions, viewCompany, type OpenTarget, type Root, @@ -61,6 +62,8 @@ const NEW_ASK = export async function renderM02MasterTemplate(root: HTMLElement): Promise { const user = await fetchSessionUser().catch(() => null); + // 구조물 옵션 정의는 M01 로 옮김(716-1) — 옛 주소는 M01 그 칸으로 + if (redirectOldOptions(user)) return { covers: () => false, show: () => {}, dispose: () => {} }; const entry = entryFromHash(user); // 134-4 — 해시에 층 없음 = 개인(회사 없으면 마스터) const company = viewCompany(entry, user); // 볼 회사(시스템 관리자는 고른 회사) let layer = layerOfRoot(entry.root); @@ -283,7 +286,7 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise false, show: () => {}, dispose: () => setActiveLayer("system") }; diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Style.css b/M02_MasterTemplete/StructOptions/M02_SO_Style.css index 32fcae514..8815da5fa 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Style.css +++ b/M02_MasterTemplete/StructOptions/M02_SO_Style.css @@ -404,3 +404,11 @@ table.m02-sod__table--wrap { word-break: keep-all; overflow-wrap: anywhere; } + +/* M01 본 칸(716-1) — 정의 화면을 얹는 틀: 머리 줄 아래 화면 */ +.m02-sod__host { + display: flex; + flex-direction: column; + gap: var(--spacing-12); + min-width: 0; +} diff --git a/ui_template/ui_template_locale_m1.ts b/ui_template/ui_template_locale_m1.ts index 04c28d9a3..6c686efd7 100644 --- a/ui_template/ui_template_locale_m1.ts +++ b/ui_template/ui_template_locale_m1.ts @@ -171,4 +171,6 @@ export const ui_locales_m1 = { B01_CalcSites_Saved: ["계산 자리 저장함", "Calc sites saved"], B01_CalcSites_Leave: ["저장 안 한 계산 자리가 있음", "You have unsaved calc sites"], B01_CalcSites_LoadFailed: ["계산 자리를 못 읽음", "Failed to load calc sites"], + B01_Dashboard_CardLogic: ["단가산출 로직", "Cost-logic library"], + B01_Dashboard_CardStructOptionDef: ["구조물 옵션 정의", "Structure option definition"], } as const; diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index 3a5811a1d..0639ba79f 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -188,4 +188,5 @@ export const ui_locales_m2 = { "그 사이 실무문서가 바뀜 — 목록을 다시 받음", "Practice documents changed — reloaded", ], + B01_Dashboard_CardPrecombo: ["일위대가", "Unit-cost items"], } as const; From a9242671dab0cfcb4dcb4d351cd5cb6e1a85581e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 14:44:14 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat(715-1a):=20M02=20=EB=8B=A8=EA=B0=80?= =?UTF-8?q?=EC=82=B0=EC=B6=9C(SK)=20=EB=AC=B8=EC=84=9C=20=EA=BC=B4=20?= =?UTF-8?q?=C2=B7=20=EA=B2=80=EC=82=AC=20=C2=B7=20=EC=A4=84=20=EC=A0=95?= =?UTF-8?q?=EC=B2=B4=20kind:=ED=82=A4#n=20=C2=B7=20=EB=8D=AE=EA=B0=9C=20?= =?UTF-8?q?=C2=B7=20=EB=8D=94=ED=95=9C=EC=A4=84=20=C2=B7=20=EB=BA=80?= =?UTF-8?q?=EC=A4=84=20=C2=B7=20=EA=B3=A0=EC=B9=9C=20=EC=A4=84=20=EC=88=9C?= =?UTF-8?q?=EC=88=98=20=ED=95=A8=EC=88=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 M02_Sangeun_Doc.py — clean(꼴 400 · 열린입력 = 배율만 · 원값 버림) · line_ids · patched(고름 · 자재표시 cid 열쇠 → 새 차례 · 짝잃음 남김) - 엔진 안 만짐 · 시험 test_715_sangeun_doc.py Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_Sangeun_Doc.py | 387 +++++++++++++++++++++++ resources/tester/test_715_sangeun_doc.py | 179 +++++++++++ 2 files changed, 566 insertions(+) create mode 100644 M02_MasterTemplete/M02_Sangeun_Doc.py create mode 100644 resources/tester/test_715_sangeun_doc.py diff --git a/M02_MasterTemplete/M02_Sangeun_Doc.py b/M02_MasterTemplete/M02_Sangeun_Doc.py new file mode 100644 index 000000000..8facdaba8 --- /dev/null +++ b/M02_MasterTemplete/M02_Sangeun_Doc.py @@ -0,0 +1,387 @@ +"""M02 단가산출(SK) 문서 — 꼴 · 검사 · 줄 정체 · 고친 줄(PLAN 715-1a · 요구서 내역12 4장 12 · 13). + +문서 `{층}/sangeun/SK-######.json` = M01 로직에 연결 + 바꾼 것만 — + {종류: "단가산출", 키, 이름, 단위(필수), 구분, 상세구분, 본뜬, + 바탕: {로직: GF…} | {참조: {층, 주인, 키: SK-…}} | null(바탕 없음 = 더한줄만), + 입력값 {이름: 값}, 열린입력 [이름](설계 때 정함 · 그 SK 품 덮개 = 배율만), 켜쌓기율?, + 고름 {cid: MT키}, 자재표시 {cid: {세 칸}}, + 덮개 {cid: {qty | 배율, labor, material, expense, 원값}}, + 더한줄 [{종류: 인력|재료|기계, 요소, 이름, 단위, 수량, 비목?, 단가?} + | {종류: 로직, 로직, 입력값, 수량} | {참조 | 고정, 이름?, 단위?, 수량}], + 뺀줄 [cid], 바탕판} +줄 정체 cid = `kind:키#n`(B09 702 덮개 꼴) — kind = labor · material · machine · logic · pct(덧줄) · +키 = 요소 키(로직 줄 = 부르는 GC 키 · 조건 재료 = 이름 · 덧줄 = 이름 · 더한줄 참조 = 그 키) · +n = 같은 머리 차례 · 바탕 줄(M01 로직 줄 · 바탕 SK 의 고친 줄)에서 셈 — 뺀줄 · 더한줄 뒤 같은 줄. +고름 · 자재표시도 cid 열쇠(호표 차례 열쇠면 뺀줄 뒤 엉뚱한 줄에 붙음) → `patched` 가 차례로 바꿈. +덮개 = 숫자만(식 안 받음 · 4-1 확정 4) — qty = 품 수 · 배율 = 품 × 율 · labor · material · expense = +줄 단가(로직 줄 = 노 · 재 · 경 묶음) · 덧줄 = qty(율 %) · 배율만 · `원값` · `바탕판` = 저장 때 +서버가 셈(브라우저 값 안 받음). 짝 잃은 덮개(M01 개정으로 줄이 사라짐) = 지우지 않고 `짝잃음` 알림. +엔진 입력 덧칸(715-1b · `master_pick`) = 호표 줄 `단가`(수) · 로직 줄 `값묶음{노무비 · 재료비 · 경비}` +""" + +from __future__ import annotations + +import re +from decimal import Decimal, InvalidOperation +from typing import Any + +KIND = "sangeun" +TITLE = "단가산출" +PREFIX = "SK" +KEY = re.compile(r"^SK-\d{6}$") +PARTS = {"labor": "노무비", "material": "재료비", "expense": "경비"} +COVER_KEYS = ("qty", "배율", *PARTS) +KINDS = {"인력": "labor", "재료": "material", "기계": "machine", "로직": "logic"} +ADD_PART = {"인력": "노무비", "재료": "재료비", "기계": "경비"} +_CID = re.compile(r"^(labor|material|machine|logic|pct):.*#\d+$") +_CALL = re.compile(r"^\s*로직\(\s*([A-Z]{2}\d{6})") +_PCT = re.compile(r"^(?P.+)\*(?P[^*]+)/\s*100\s*$", re.S) # 「X * Y / 100」 +_NAME = re.compile(r"^[\w가-힣]+$") + + +class DocError(ValueError): + def __init__(self, text: str, status: int = 400) -> None: + super().__init__(text) + self.status = status + + +def _num(raw: Any, where: str) -> Decimal: + try: + got = Decimal(str(raw).replace(",", "").strip()) + except (InvalidOperation, TypeError): + got = None + if raw is None or isinstance(raw, bool) or got is None or not got.is_finite(): + raise DocError(f"{where} · 수가 아님 「{raw}」") + return got + + +def plain(value: Decimal) -> int | float: + return int(value) if value == value.to_integral_value() else float(value) + + +def text(value: Any) -> str: + """식 안에 넣을 수 글 — 지수 꼴 없이.""" + got = value if isinstance(value, Decimal) else Decimal(str(value)) + return format(got.normalize(), "f") + + +# ── 줄 정체 ─────────────────────────────────────────────────────────── +def _head(item: dict, extra: bool) -> str: + if extra: + return f"pct:{item.get('이름') or ''}" + kind = KINDS.get(str(item.get("종류")), "logic") + elem = item.get("요소") + if isinstance(item.get("참조"), dict): + key = str(item["참조"].get("키") or "") + elif isinstance(item.get("고정"), dict): + key = str(item["고정"].get("이름") or "") + elif kind == "logic": + found = _CALL.match(str(elem or "")) + key = found.group(1) if found else str(item.get("이름") or "") + else: + key = elem if isinstance(elem, str) else str(item.get("이름") or "") + return f"{kind}:{key}" + + +def line_ids(row: dict) -> list[str]: + """로직 줄(호표 → 덧줄 차례)마다 cid — 같은 머리 n번째 = `#n`.""" + seen: dict[str, int] = {} + out = [] + pairs = [(x, False) for x in row.get("호표") or []] + [(x, True) for x in row.get("덧줄") or []] + for item, extra in pairs: + head = _head(item, extra) + seen[head] = seen.get(head, -1) + 1 + out.append(f"{head}#{seen[head]}") + return out + + +# ── 정리 · 검사 ─────────────────────────────────────────────────────── +def _cid(raw: Any, where: str) -> str: + if not isinstance(raw, str) or not _CID.match(raw): + raise DocError(f"{where} · 줄 정체는 kind:키#n 「{raw}」") + return raw + + +def _base(raw: Any) -> dict | None: + if raw is None: + return None + if isinstance(raw, dict) and isinstance(raw.get("로직"), str) and raw["로직"].strip(): + return {"로직": raw["로직"].strip()} + if isinstance(raw, dict) and isinstance(raw.get("참조"), dict): + ref = raw["참조"] + if not KEY.match(str(ref.get("키") or "")): + raise DocError("바탕 참조 키는 SK-######") + return {"참조": {"층": ref.get("층"), "주인": ref.get("주인"), "키": str(ref["키"])}} + raise DocError("바탕은 {로직} · {참조} · null") + + +def _given(raw: Any, where: str) -> dict: + if raw is None: + return {} + if not isinstance(raw, dict): + raise DocError(f"{where} · 입력값이 묶음 아님") + return {str(k): v for k, v in raw.items() if v not in (None, "")} + + +def _cover(raw: Any, where: str, opened: bool) -> dict: + if not isinstance(raw, dict): + raise DocError(f"{where} · 덮개는 묶음") + unknown = set(raw) - {*COVER_KEYS, "원값"} + if unknown: + raise DocError(f"{where} · 모르는 덮개 칸 {sorted(unknown)}") + out = {k: plain(_num(raw[k], f"{where} {k}")) for k in COVER_KEYS if raw.get(k) is not None} + if "qty" in out and "배율" in out: + raise DocError(f"{where} · 품은 qty · 배율 중 하나") + if opened and "qty" in out: + raise DocError(f"{where} · 옵션을 열어 둔 단가산출은 품 배율만") + if any(v < 0 for v in out.values()): + raise DocError(f"{where} · 음수 덮개") + if not out: + raise DocError(f"{where} · 빈 덮개") + return out + + +def _added(raw: Any, where: str, whole: Any) -> dict: + """더한줄 하나 — 수량은 숫자(식 안 받음).""" + if not isinstance(raw, dict): + raise DocError(f"{where} · 묶음 아님") + qty = plain(_num(raw.get("수량"), f"{where} 수량")) + name, unit = str(raw.get("이름") or "").strip(), str(raw.get("단위") or "").strip() + if isinstance(raw.get("참조"), dict): + ref = raw["참조"] + if not re.match(r"^(SK|SC)-\d{6}$", str(ref.get("키") or "")): + raise DocError(f"{where} · 참조 키는 SK · SC") + head: dict[str, Any] = {"참조": {k: ref.get(k) for k in ("층", "주인", "키")}} + elif isinstance(raw.get("고정"), dict): + from M02_MasterTemplete import M02_Labor_Fixed as fixed + + try: + head = {"고정": fixed.clean(raw["고정"], where, whole) if whole else raw["고정"]} + except Exception as e: # noqa: BLE001 — 고정 줄 검사 = StoreError(400) + raise DocError(str(getattr(e, "detail", e))) from e + if head["고정"].get("비율"): + raise DocError(f"{where} · 비율 고정 줄은 단가산출에 못 담음 — 덧줄 율로") + elif raw.get("종류") == "로직": + code = str(raw.get("로직") or "") + row = _logic(whole, code, where) + names = {str(s.get("이름")) for s in (row or {}).get("입력") or []} + given = _given(raw.get("입력값"), where) + head = {"종류": "로직", "로직": code, "입력값": given} + if row is not None: + head["입력값"] = {k: v for k, v in given.items() if k in names} + name = name or str(row.get("이름") or code) + elif raw.get("종류") in ADD_PART: + key = str(raw.get("요소") or "") + got = _element(whole, key, where) + part = str(raw.get("비목") or ADD_PART[raw["종류"]]) + if part not in PARTS.values(): + raise DocError(f"{where} · 비목은 노무비 · 재료비 · 경비") + head = {"종류": raw["종류"], "요소": key, "비목": part} + if raw.get("단가") is not None: + head["단가"] = plain(_num(raw["단가"], f"{where} 단가")) + name = name or str((got or {}).get("이름") or key) + unit = unit or str((got or {}).get("단위") or "") + else: + raise DocError(f"{where} · 종류는 인력 · 재료 · 기계 · 로직 · 참조 · 고정") + return {**head, "이름": name, "단위": unit, "수량": qty} + + +def _logic(whole: Any, code: str, where: str) -> dict | None: + if whole is None: + return None + try: + return whole.logic(code) + except Exception as e: # noqa: BLE001 — 엔진 FormulaError(가져오기 둘레 피함) + raise DocError(f"{where} · {e}") from e + + +def _element(whole: Any, key: str, where: str) -> dict | None: + if not key: + raise DocError(f"{where} · 요소 키가 빔") + if whole is None: + return None + try: + return whole.get(key) + except Exception as e: # noqa: BLE001 + raise DocError(f"{where} · 없는 요소 「{key}」") from e + + +def clean(doc: Any, base_row: dict | None = None, whole: Any = None) -> dict[str, Any]: + """서버가 받는 꼴로 — 틀리면 `DocError`(400). `base_row` = 패치를 얹을 바탕 줄(입력 이름 · + 열린입력 검사) · `whole` = M01(요소 · 로직 · 자재 키 검사). 짝 없는 cid 는 받음(셈 때 알림) · + `원값` · `바탕판` 은 서버 몫이라 버림.""" + if not isinstance(doc, dict): + raise DocError("단가산출 문서가 묶음 아님") + key = str(doc.get("키") or "") + if not KEY.match(key): + raise DocError("단가산출 키는 SK-######") + name, unit = str(doc.get("이름") or "").strip(), str(doc.get("단위") or "").strip() + if not name: + raise DocError("단가산출 이름이 빔") + if not unit: + raise DocError("단가산출 단위가 빔 — 1 단위 값의 단위") + base = _base(doc.get("바탕")) + if base and "로직" in base and whole is not None and base_row is None: + base_row = _logic(whole, base["로직"], "바탕") + names = {str(s.get("이름")) for s in (base_row or {}).get("입력") or []} + given = _given(doc.get("입력값"), "입력값") + opened = [str(x) for x in doc.get("열린입력") or []] + if base_row is not None: + given = {k: v for k, v in given.items() if k in names} + bad = [x for x in opened if x not in names or x in given] + if bad: + raise DocError(f"열린입력은 바탕 입력 중 값 안 넣은 것만 {bad}") + out: dict[str, Any] = {"종류": TITLE, "키": key, "이름": name, "단위": unit, "바탕": base} + out.update({k: str(doc.get(k) or "").strip() for k in ("구분", "상세구분")}) + out["본뜬"] = doc["본뜬"] if isinstance(doc.get("본뜬"), dict) else None + out["입력값"], out["열린입력"] = given, opened + if doc.get("켜쌓기율") is not None: + out["켜쌓기율"] = plain(_num(doc["켜쌓기율"], "켜쌓기율")) + picks = {} + for cid, mt in (doc.get("고름") or {}).items(): + mt = str(mt or "") + if not mt.startswith("MT") or (whole is not None and _element(whole, mt, "고름") is None): + raise DocError(f"고름 「{cid}」 · M01 자재품목 키 아님 「{mt}」") + picks[_cid(cid, "고름")] = mt + out["고름"] = picks + out["자재표시"] = _marks(doc.get("자재표시")) + out["덮개"] = { + _cid(cid, "덮개"): _cover(one, f"덮개 「{cid}」", bool(opened)) + for cid, one in (doc.get("덮개") or {}).items() + } + out["더한줄"] = [ + _added(one, f"더한줄 {at}째", whole) for at, one in enumerate(doc.get("더한줄") or [], 1) + ] + drop = [_cid(x, "뺀줄") for x in doc.get("뺀줄") or []] + if len(set(drop)) != len(drop): + raise DocError("뺀줄에 같은 줄 두 번") + out["뺀줄"] = drop + return out + + +def _marks(raw: Any) -> dict[str, dict[str, str]]: + from M02_MasterTemplete import M02_Labor_Doc as labor + + out = {} + for cid, one in (raw or {}).items() if isinstance(raw, dict) else (): + got = labor.marks(one) + if got: + out[_cid(cid, "자재표시")] = got + return out + + +# ── 고친 줄 ─────────────────────────────────────────────────────────── +def _covered(item: dict, cover: dict, extra: bool) -> tuple[dict, str | None]: + """덮개 한 줄을 로직 줄에 — (고친 줄, 못 덮은 까닭).""" + item = dict(item) + if extra: # 덧줄 = 율(qty) · 배율만 + if any(k in cover for k in PARTS): + return item, "덧줄은 율만 고침" + formula = str(item.get("식") or "") + if "배율" in cover: + item["식"] = f"({formula}) * {text(cover['배율'])}" + return item, None + found = _PCT.match(formula) + if not found: + return item, "덧줄 식이 「X * 율 / 100」 꼴 아님" + item["식"] = f"{found.group('x').strip()} * {text(cover['qty'])} / 100" + return item, None + if "qty" in cover: + item["수량"] = text(cover["qty"]) + elif "배율" in cover: + item["수량"] = f"({item.get('수량')}) * {text(cover['배율'])}" + prices = {PARTS[k]: Decimal(str(cover[k])) for k in PARTS if k in cover} + if not prices: + return item, None + if item.get("종류") == "로직": + item["값묶음"] = {**dict(item.get("값묶음") or {}), **prices} + return item, None + part = str(item.get("비목") or "") + if set(prices) != {part}: + return item, f"비목 다름 — 이 줄은 {part or '비목 없음'}" + item["단가"] = prices[part] + return item, None + + +def _add_item(one: dict) -> dict: + """더한줄 → 엔진 호표 줄 — 참조 · 고정 줄 `값묶음` 은 셈(`M02_Sangeun_Calc`)이 채움.""" + head = {"이름": one.get("이름") or "", "단위": one.get("단위") or ""} + qty = text(one["수량"]) + if "참조" in one or "고정" in one: + kept = {k: one[k] for k in ("참조", "고정") if k in one} + return {**head, "종류": "로직", "요소": None, "수량": qty, **kept, "값묶음": None} + if one.get("종류") == "로직": + args = "".join( + f", {k if _NAME.match(k) else repr(k)}=" + + (text(v) if isinstance(v, (int, float, Decimal)) else f"'{v}'") + for k, v in (one.get("입력값") or {}).items() + ) + return {**head, "종류": "로직", "요소": f"로직({one['로직']}{args})", "수량": qty} + item = {**head, "종류": one["종류"], "요소": one["요소"], "수량": qty, "비목": one["비목"]} + return {**item, **({"단가": Decimal(str(one["단가"]))} if "단가" in one else {})} + + +def patched( + row: dict, doc: dict, picks: dict | None = None, shown: dict | None = None +) -> dict[str, Any]: + """바탕 줄(M01 로직 줄 · 바탕 SK 의 고친 줄) + SK 패치 → 고친 줄(순수 · 엔진 안 부름). + `picks` · `shown` = 바탕 쪽 차례 열쇠 고름 · 자재표시(바탕 SK 사슬) → 새 차례로 옮김. + 돌려줌 = {줄, 고름 {차례: MT}, 자재표시 {차례: 칸}, 정체 [새 줄 cid · 더한줄 = `add:#k`], + 짝잃음 [{cid, 까닭}]}.""" + items, extras = list(row.get("호표") or []), list(row.get("덧줄") or []) + ids = line_ids(row) + covers, drop = doc.get("덮개") or {}, set(doc.get("뺀줄") or []) + mine = doc.get("고름") or {} + marks = doc.get("자재표시") or {} + lost = [ + {"cid": c, "까닭": "뺀줄 — 바탕에 없는 줄"} for c in doc.get("뺀줄") or [] if c not in ids + ] + new_items, new_extras, cids, out_picks, out_marks = [], [], [], {}, {} + for at, cid in enumerate(ids): + if cid in drop: + continue + extra = at >= len(items) + item = extras[at - len(items)] if extra else items[at] + if cid in covers: + item, why = _covered(item, covers[cid], extra) + if why: + lost.append({"cid": cid, "까닭": why}) + if extra: + new_extras.append(item) + continue + n = str(len(new_items)) + old = (picks or {}).get(str(at)) + if cid in mine and item.get("종류") != "재료": + lost.append({"cid": cid, "까닭": "고름 — 재료 줄 아님"}) + elif cid in mine or old: + out_picks[n] = mine.get(cid) or old + if (shown or {}).get(str(at)) or cid in marks: + out_marks[n] = marks.get(cid) or shown[str(at)] + new_items.append(item) + cids.append(cid) + for k, one in enumerate(doc.get("더한줄") or []): + new_items.append(_add_item(one)) + cids.append(f"add:#{k}") + cids += [c for at, c in enumerate(ids) if at >= len(items) and c not in drop] + for name, keys in (("덮개", covers), ("고름", mine), ("자재표시", marks)): + lost += [{"cid": c, "까닭": f"{name} — 바탕에 없는 줄"} for c in keys if c not in ids] + return { + "줄": {**row, "호표": new_items, "덧줄": new_extras}, + "고름": out_picks, + "자재표시": out_marks, + "정체": cids, + "짝잃음": lost, + } + + +def empty_row(doc: dict) -> dict[str, Any]: + """바탕 없는 단가산출의 바탕 줄 — 입력 · 줄 없음(더한줄만 셈).""" + return { + "키": doc.get("키"), + "이름": doc.get("이름"), + "결과단위": f"원/{doc.get('단위') or ''}", + "입력": [], + "중간": [], + "호표": [], + "덧줄": [], + } diff --git a/resources/tester/test_715_sangeun_doc.py b/resources/tester/test_715_sangeun_doc.py new file mode 100644 index 000000000..dc66e4514 --- /dev/null +++ b/resources/tester/test_715_sangeun_doc.py @@ -0,0 +1,179 @@ +"""M02 단가산출(SK) 문서 꼴 · 줄 정체 · 고친 줄(PLAN 715-1a · `M02_Sangeun_Doc`) — 엔진 안 부름.""" + +from decimal import Decimal + +import pytest + +from M02_MasterTemplete import M02_Sangeun_Doc as sd + +ROW = { + "키": "GX000001", + "결과단위": "원/㎡", + "입력": [{"이름": "높이"}, {"이름": "돌", "고르기": ["깬잡석", "견치돌"]}], + "호표": [ + { + "종류": "인력", + "요소": "LB000033", + "이름": "석공", + "수량": "높이 * 0.1", + "비목": "노무비", + }, + {"종류": "재료", "요소": {"검색어": "모래"}, "이름": "모래", "수량": "1", "비목": "재료비"}, + {"종류": "인력", "요소": "LB000033", "이름": "석공", "수량": "0.2", "비목": "노무비"}, + {"종류": "재료", "요소": {"검색어": "자갈"}, "이름": "자갈", "수량": "2", "비목": "재료비"}, + { + "종류": "로직", + "요소": "로직(GC000276, 기계='5401-0017')", + "이름": "굴착기", + "수량": "0.5", + }, + ], + "덧줄": [{"이름": "공구손료", "식": "(석공 * LB000033) * 3 / 100", "비목": "경비"}], +} + + +def _doc(**extra): + return { + "키": "SK-000001", + "이름": "돌쌓기 내 값", + "단위": "㎡", + "바탕": {"로직": "GX000001"}, + **extra, + } + + +def test_line_ids_kind_key_n(): + assert sd.line_ids(ROW) == [ + "labor:LB000033#0", + "material:모래#0", + "labor:LB000033#1", + "material:자갈#0", + "logic:GC000276#0", + "pct:공구손료#0", + ] + + +def test_clean_shape_and_refusals(): + got = sd.clean(_doc(입력값={"높이": 3, "모름": 1, "돌": ""}, 원값=1), ROW) + assert got["입력값"] == {"높이": 3} and got["종류"] == "단가산출" and got["뺀줄"] == [] + for bad, why in ( + ({"키": "SC-000001"}, "SK-"), + ({"이름": " "}, "이름"), + ({"단위": ""}, "단위"), + ({"바탕": {"참조": {"키": "SC-000001"}}}, "SK-"), + ({"덮개": {"labor:LB000033#0": {"qty": "식"}}}, "수가 아님"), + ({"덮개": {"labor:LB000033#0": {"qty": 1, "배율": 2}}}, "하나"), + ({"덮개": {"labor:LB#x": {"qty": 1}}}, "kind:키#n"), + ({"뺀줄": ["material:모래#0", "material:모래#0"]}, "두 번"), + ({"열린입력": ["높이"], "입력값": {"높이": 3}}, "열린입력"), + ({"더한줄": [{"종류": "인력", "요소": "LB000002"}]}, "수량"), + ({"더한줄": [{"종류": "식", "수량": 1}]}, "종류"), + ): + with pytest.raises(sd.DocError, match=why): + sd.clean(_doc(**bad), ROW) + # 원값 · 바탕판 = 서버 몫 — 브라우저 값 버림 + got = sd.clean(_doc(덮개={"labor:LB000033#0": {"qty": 0.5, "원값": 9}}), ROW) + assert got["덮개"] == {"labor:LB000033#0": {"qty": 0.5}} + + +def test_open_inputs_only_ratio(): + with pytest.raises(sd.DocError, match="배율만"): + sd.clean(_doc(열린입력=["돌"], 덮개={"labor:LB000033#0": {"qty": 1}}), ROW) + got = sd.clean(_doc(열린입력=["돌"], 덮개={"labor:LB000033#0": {"배율": 1.1}}), ROW) + assert got["덮개"]["labor:LB000033#0"] == {"배율": 1.1} + + +def test_patched_cover_qty_price_ratio(): + doc = sd.clean( + _doc( + 덮개={ + "labor:LB000033#1": {"qty": 0.25, "labor": 200000}, + "labor:LB000033#0": {"배율": 1.5}, + "logic:GC000276#0": {"expense": 1000}, + "pct:공구손료#0": {"qty": 5}, + } + ), + ROW, + ) + got = sd.patched(ROW, doc) + rows = got["줄"]["호표"] + assert rows[0]["수량"] == "(높이 * 0.1) * 1.5" + assert rows[2]["수량"] == "0.25" and rows[2]["단가"] == Decimal(200000) + assert rows[4]["값묶음"] == {"경비": Decimal(1000)} + assert got["줄"]["덧줄"][0]["식"] == "(석공 * LB000033) * 5 / 100" + assert got["짝잃음"] == [] and ROW["호표"][2]["수량"] == "0.2" # 바탕은 안 바뀜 + + +def test_drop_keeps_pick_on_its_line(): + """뺀줄 뒤 고름 제자리 — 자갈(바탕 3째) 고름이 뺀줄 뒤 새 2째로 옮겨 감 · 모래 줄에 안 붙음.""" + doc = sd.clean(_doc(뺀줄=["material:모래#0"], 고름={"material:자갈#0": "MT000010"}), ROW) + got = sd.patched(ROW, doc) + names = [x["이름"] for x in got["줄"]["호표"]] + assert names == ["석공", "석공", "자갈", "굴착기"] + assert got["고름"] == {"2": "MT000010"} + assert got["정체"][:4] == [ + "labor:LB000033#0", + "labor:LB000033#1", + "material:자갈#0", + "logic:GC000276#0", + ] + + +def test_added_lines_before_extras(): + doc = sd.clean( + _doc( + 더한줄=[ + {"종류": "인력", "요소": "LB000002", "이름": "보통인부", "단위": "인", "수량": 0.1}, + { + "종류": "로직", + "로직": "GC000001", + "입력값": {"규격": "가", "두께": 2}, + "수량": 1, + }, + {"참조": {"층": "system", "주인": None, "키": "SK-000002"}, "수량": 2}, + ] + ), + ROW, + ) + got = sd.patched(ROW, doc) + added = got["줄"]["호표"][5:] + assert added[0]["비목"] == "노무비" and added[0]["수량"] == "0.1" + assert added[1]["요소"] == "로직(GC000001, 규격='가', 두께=2)" + assert added[2]["참조"]["키"] == "SK-000002" and added[2]["값묶음"] is None + assert got["정체"][5:] == ["add:#0", "add:#1", "add:#2", "pct:공구손료#0"] + + +def test_lost_covers_kept_and_listed(): + """짝 잃은 덮개 = 문서에 남김 + 목록(지우지 않음) · 비목 다른 단가 · 재료 아닌 고름.""" + doc = sd.clean( + _doc( + 덮개={"labor:LB999999#0": {"qty": 1}, "labor:LB000033#0": {"material": 5}}, + 고름={"labor:LB000033#1": "MT000010"}, + 뺀줄=["material:없음#0"], + ), + ROW, + ) + got = sd.patched(ROW, doc) + why = {x["cid"]: x["까닭"] for x in got["짝잃음"]} + assert why["labor:LB999999#0"].startswith("덮개") and "비목 다름" in why["labor:LB000033#0"] + assert why["labor:LB000033#1"].startswith("고름") and why["material:없음#0"].startswith("뺀줄") + assert "labor:LB999999#0" in doc["덮개"] + + +def test_chain_moves_base_picks(): + """바탕 SK 사슬 — 바탕 쪽 차례 고름이 아래 SK 뺀줄 뒤에도 그 줄에.""" + first = sd.patched(ROW, sd.clean(_doc(고름={"material:자갈#0": "MT000010"}), ROW)) + child = sd.clean({**_doc(뺀줄=["labor:LB000033#0"]), "바탕": None}, first["줄"]) + got = sd.patched(first["줄"], child, first["고름"]) + assert got["고름"] == {"2": "MT000010"} and got["줄"]["호표"][2]["이름"] == "자갈" + + +def test_empty_base(): + row = sd.empty_row({"키": "SK-000003", "이름": "자체", "단위": "식"}) + doc = sd.clean( + {"키": "SK-000003", "이름": "자체", "단위": "식", "바탕": None, + "더한줄": [{"종류": "재료", "요소": "MT000010", "수량": 3, "단가": 1200}]}, + row, + ) # fmt: skip + got = sd.patched(row, doc) + assert got["줄"]["호표"][0]["단가"] == Decimal(1200) and got["줄"]["결과단위"] == "원/식" From 2939bff074a2e3e43e0bfa0e29951e6b250958b6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 14:44:30 +0900 Subject: [PATCH 3/5] =?UTF-8?q?style(715-1a):=20M02=5FSangeun=5FDoc=20?= =?UTF-8?q?=EB=A8=B8=EB=A6=AC=20=EA=B8=80=20=EC=A4=84=20=EA=B8=B8=EC=9D=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_Sangeun_Doc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/M02_MasterTemplete/M02_Sangeun_Doc.py b/M02_MasterTemplete/M02_Sangeun_Doc.py index 8facdaba8..f9c04e50d 100644 --- a/M02_MasterTemplete/M02_Sangeun_Doc.py +++ b/M02_MasterTemplete/M02_Sangeun_Doc.py @@ -16,7 +16,7 @@ n = 같은 머리 차례 · 바탕 줄(M01 로직 줄 · 바탕 SK 의 고친 덮개 = 숫자만(식 안 받음 · 4-1 확정 4) — qty = 품 수 · 배율 = 품 × 율 · labor · material · expense = 줄 단가(로직 줄 = 노 · 재 · 경 묶음) · 덧줄 = qty(율 %) · 배율만 · `원값` · `바탕판` = 저장 때 서버가 셈(브라우저 값 안 받음). 짝 잃은 덮개(M01 개정으로 줄이 사라짐) = 지우지 않고 `짝잃음` 알림. -엔진 입력 덧칸(715-1b · `master_pick`) = 호표 줄 `단가`(수) · 로직 줄 `값묶음{노무비 · 재료비 · 경비}` +엔진 입력 덧칸(715-1b · `master_pick`) = 줄 `단가`(수) · 로직 줄 `값묶음{노무비 · 재료비 · 경비}` """ from __future__ import annotations From a2742d528bbaf76b5f71abd5ed79986cece2d51f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 14:46:48 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat(716-1=C2=B7716-2):=20=EC=98=B5?= =?UTF-8?q?=EC=85=98=20=EC=B9=B8=20=EC=9C=84=20=EA=B5=AC=EB=B6=84=EC=84=A0?= =?UTF-8?q?=20=EB=B6=80=ED=92=88=20=C2=B7=20=EC=8B=9C=ED=97=98=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M01 로직 칸 아래 구분선 = 부품 createDivider - 시험 셋 새 꼴로: 대시보드 줄 칸별(M01 3 · M02 3 · 계산 자리) · ☰ 페이지 둘(역할별) · 옛 open=옵션 돌려보냄 · M01 open 인자 - 낡은 시험 고침 까닭: 옵션 줄이 M01 로 옮겨 카드 순서 · 해시가 바뀜(test_b01_settings_cards · test_197_1 · test_309_2) Co-Authored-By: Claude Sonnet 5.5 --- M01_MasterData/M01_MasterData_UI_Side.ts | 3 +- .../test_197_1_dashboard_option_card.py | 62 ++++++++++++--- resources/tester/test_309_2_calc_sites.py | 2 +- resources/tester/test_b01_settings_cards.py | 76 +++++++++++++++---- 4 files changed, 116 insertions(+), 27 deletions(-) diff --git a/M01_MasterData/M01_MasterData_UI_Side.ts b/M01_MasterData/M01_MasterData_UI_Side.ts index 1c7e90b0e..5dc8f06a7 100644 --- a/M01_MasterData/M01_MasterData_UI_Side.ts +++ b/M01_MasterData/M01_MasterData_UI_Side.ts @@ -8,6 +8,7 @@ import { createSelectField, el, showToast } from "@ui/ui_template_elements"; import { attachCollapsible } from "@ui/ui_template_collapsible"; +import { createDivider } from "@ui/ui_template_divider"; import { currentLanguageIndex, t as L } from "@ui/ui_template_locale"; import { buildOptionsSection, @@ -501,7 +502,7 @@ ${first.file}` const optionsSec = section(options.title, options.body); const root = el("div", { className: "m01-side ui-sidebar-panel", - children: [...groups, logic, el("hr", { className: "ui-sidebar-divider" }), optionsSec], + children: [...groups, logic, createDivider(), optionsSec], }); attachCollapsible(root); void Promise.all(GROUPS.filter((g) => g !== FUEL_GROUP).map(refresh)).catch((error) => diff --git a/resources/tester/test_197_1_dashboard_option_card.py b/resources/tester/test_197_1_dashboard_option_card.py index 9bb89ea86..011bbbe1c 100644 --- a/resources/tester/test_197_1_dashboard_option_card.py +++ b/resources/tester/test_197_1_dashboard_option_card.py @@ -1,10 +1,14 @@ -"""197-1 대시보드 「구조물 옵션」 카드 → M02 「구조물 옵션 정의」 화면이 바로 열림 시험 — 조작은 사용자. +"""197-1 · 716-1 대시보드 「구조물 옵션 정의」 줄 → M01 「구조물 옵션 정의」 화면이 바로 열림 시험 — 조작은 ORCA. + +716-1 — 화면 입구가 M02 에서 M01 로 옮겨감(화면 코드 · 서버 길은 M02 폴더 그대로) · 옛 M02 `open=옵션` 주소는 +시스템 관리자면 M01 `?open=옵션` 으로 돌려보냄. 원인 — 진입 해시 `open=옵션` 이 옵션 줄을 눌러 정의 화면을 띄우는 사이 첫 화면(집계표)의 `mount` 가 모듈 · 번호를 기다리다 뒤늦게 끝나 정의 화면 위에 집계표를 덮음(`mount` 에 낡음 검사 없었음). -· 카드 셋 → 해시(open=구조물조합 · 옵션 · 도면) · 해시 → 진입(`entryFromHash`) Node 로 바로 돌림 -· `reveal` 가 옵션만 줄을 누르고 도면은 펼치기만 · 구조물조합은 아무것도 안 함 +· 줄 → 해시(M01 open=로직 · 옵션 / M02 open=일위대가조합 · 구조물조합 · 도면) · 해시 → 진입(`entryFromHash`) Node +· M02 `reveal` 가 옵션만 줄을 누르고 도면은 펼치기만 · 구조물조합은 아무것도 안 함 · M01 `reveal` 도 같은 꼴 +· 옛 `open=옵션` 돌려보냄(`redirectOldOptions`) · `mount` 집계표 갈래(진짜 소스 조각)를 Node 로 돌림 — 기다리는 사이 다른 화면이 열리면 그리지 않음 """ @@ -41,16 +45,19 @@ def _run(script: str, *args: str) -> object: @needs_node -def test_카드_셋_해시(): +def test_줄_해시_옵션은_M01(): from test_b01_settings_cards import _cards cards = _cards({"role": "SYSTEM_ADMIN", "is_master": False}, 3) - assert [c[2].split("?")[1].split("&")[0] for c in cards[1:4]] == [ - "open=구조물조합", - "open=옵션", - "open=도면", + heads = [c[2].split("?")[0] + "?" + c[2].split("?")[1].split("&")[0] for c in cards[1:6]] + assert heads == [ + "m01-master-data?open=로직", + "m01-master-data?open=옵션", + "m02-master-template?open=일위대가조합", + "m02-master-template?open=구조물조합", + "m02-master-template?open=도면", ] - assert all("&root=master" in c[2] for c in cards[1:4]) + assert all("&root=master" in c[2] for c in cards[3:6]) @needs_node @@ -69,7 +76,7 @@ def test_해시_진입_셋(): + fn.group(0).replace("export function", "function") + "\nconsole.log(JSON.stringify(entryFromHash(null)));" ) - for want in ("구조물조합", "옵션", "도면"): + for want in ("구조물조합", "옵션", "도면", "일위대가조합"): got = _run(script, f"open={want}&root=master&company=3") assert got["open"] == want and got["root"] == "master" and got["company"] == "3" @@ -139,3 +146,38 @@ def test_집계표_갈래_기다리는_사이_다른_화면이_열리면_그리 assert got["none"] == ["create", "panel:set"] # 안 끼어들면 그대로 그림 assert got["mod"] == ["panel:null"] # 모듈 기다리는 사이 다른 화면 — 그리지 않음 assert got["num"] == ["panel.destroy", "panel:null"] # 번호 기다리는 사이 — 패널도 버림 + + +@needs_node +def test_옛_옵션_주소_M01로_돌려보냄(): + layer = _text(M02 / "M02_MasterTemplete_Layer.ts") + fn = re.search(r"^export function redirectOldOptions\([\s\S]*?^}", layer, re.M) + assert fn, "redirectOldOptions 를 못 찾음" + script = ( + "const currentQuery = () => new URLSearchParams(process.argv[1]);\n" + "const ROUTES = { M01_MASTER_DATA: 'm01' }; const sent: string[] = [];\n" + "const navigateTo = (r: string, q: any) => sent.push(r + JSON.stringify(q));\n" + "type SessionUser = any;\n" + + fn.group(0).replace("export function", "function") + + "\nconst a = redirectOldOptions({ role: 'SYSTEM_ADMIN' });" + + "\nconst b = redirectOldOptions({ role: 'USER' });" + + "\nconst c = redirectOldOptions(null);" + + "\nconsole.log(JSON.stringify([a, b, c, sent]));" + ) + assert _run(script, "open=옵션&root=master") == [True, False, False, ['m01{"open":"옵션"}']] + # 옵션이 아니면 안 돌려보냄 + assert _run(script, "open=도면&root=master") == [False, False, False, []] + page = _text(M02 / "M02_MasterTemplete_UI_Page.ts") + assert "if (redirectOldOptions(user))" in page + assert 'mountSettingsDrawer(user ?? {}, "m02", entry.company);' in page + + +def test_M01_open_인자_로직_옵션(): + m01 = ROOT / "M01_MasterData" + page = _text(m01 / "M01_MasterData_UI_Page.ts") + assert 'if (want === "로직" || want === "옵션") side.reveal(want);' in page + assert "setLeaveGuard(leaveOptions)" in page # 저장 안 한 정의 고침 — 떠날 때 물음 + side = _text(m01 / "M01_MasterData_UI_Side.ts") + assert "buildOptionsSection({ ...optionsOpt, isAdmin: true })" in side + # 로직 칸 아래 구분선 부품 + 옵션 칸 + assert "logic, createDivider(), optionsSec" in side diff --git a/resources/tester/test_309_2_calc_sites.py b/resources/tester/test_309_2_calc_sites.py index 4a366502d..df6a56949 100644 --- a/resources/tester/test_309_2_calc_sites.py +++ b/resources/tester/test_309_2_calc_sites.py @@ -263,7 +263,7 @@ def test_카드_시스템_관리자만_글_다_있음(): links = nav.split("export function mountSettingsDrawer")[0] admin, rest = links.split('if (user.role === "SYSTEM_ADMIN") {')[1].split("// 회사 수정 =") assert 'key: "calcSites"' in admin and "calcSites" not in rest - assert 'settingLinks(user, companyId).filter((link) => link.key !== "calcSites")' in nav + assert "calcSites" not in nav.split("export function pageLinks")[1] # ☰ 는 페이지 둘만(716-2) page = _text("B01_Dashboard/B01_Dashboard_UI_View_SysDefaults.ts") assert 'link.key === "calcSites" ? void openCalcSitesDialog()' in page locale = _text("ui_template/ui_template_locale_m1.ts") diff --git a/resources/tester/test_b01_settings_cards.py b/resources/tester/test_b01_settings_cards.py index 8220ed667..a32175595 100644 --- a/resources/tester/test_b01_settings_cards.py +++ b/resources/tester/test_b01_settings_cards.py @@ -58,7 +58,36 @@ def _cards(user: dict, company_id: int | None = None) -> list: return json.loads(result.stdout) +def _pages(user: dict, company_id: int | None = None) -> list: + # ☰ 메뉴 = pageLinks(settingLinks 의 구조물 조합 줄을 바탕으로 페이지 둘만) + script = ( + "const ROUTES = { M01_MASTER_DATA: 'm01-master-data'," + " M02_MASTER_TEMPLATE: 'm02-master-template', B01_ACCOUNT: 'b01-account' };\n" + "const L = (k: string) => k;\n" + "let went = '';\n" + "const navigateTo = (r: string, q?: Record) =>" + " { went = r + (q ? '?' + new URLSearchParams(q) : ''); };\n" + + _fn(_text(NAV), "settingLinks") + + "\n" + + _fn(_text(NAV), "pageLinks") + + "\nconst user = JSON.parse(process.argv[1]);" + + "\nconst links = pageLinks(user, JSON.parse(process.argv[2]));" + + "\nconsole.log(JSON.stringify(links.map((l: any) => {" + + " navigateTo(l.route, l.query); return [l.key, l.title, decodeURIComponent(went)]; })));" + ) + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", script, json.dumps(user), json.dumps(company_id)], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +M01 = "m01-master-data" M02 = "m02-master-template" +READ_SRC = "B01_Dashboard_PermSourceRead" +WRITE_M = "B01_Dashboard_PermMasterWrite" # 계산 자리(504-2) — 시스템 관리자만 · 이동 대신 창 CALC_SITES = ["B01_CalcSites_Title", "B01_CalcSites_Desc", "calc-sites-dialog"] READ_CO = ( @@ -68,21 +97,40 @@ WRITE_CO = READ_CO.replace("CompanyRead", "CompanyWrite") @pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") -def test_시스템_관리자_카드_다섯_고른_회사_해시(): - m = "B01_Dashboard_PermMasterWrite" +def test_시스템_관리자_줄_칸별_일곱_고른_회사_해시(): + # 716-2 — 대시보드 설계 기본값 = 칸별 줄: M01 3(마스터 요소 · 단가산출 로직 · 구조물 옵션 정의) + + # M02 3(일위대가 · 구조물 조합 · 도면 양식) + 계산 자리 assert _cards({"role": "SYSTEM_ADMIN", "is_master": False}, 3) == [ - ["B01_Dashboard_MasterElements", "B01_Dashboard_PermSourceRead", "m01-master-data"], - ["B01_Dashboard_CardStructCombo", m, f"{M02}?open=구조물조합&root=master&company=3"], - ["B01_Dashboard_CardStructOption", m, f"{M02}?open=옵션&root=master&company=3"], - ["B01_Dashboard_CardDrawing", m, f"{M02}?open=도면&root=master&company=3"], + ["M01_Title", READ_SRC, M01], + ["B01_Dashboard_CardLogic", READ_SRC, f"{M01}?open=로직"], + ["B01_Dashboard_CardStructOptionDef", WRITE_M, f"{M01}?open=옵션"], + ["B01_Dashboard_CardPrecombo", WRITE_M, f"{M02}?open=일위대가조합&root=master&company=3"], + ["B01_Dashboard_CardStructCombo", WRITE_M, f"{M02}?open=구조물조합&root=master&company=3"], + ["B01_Dashboard_CardDrawing", WRITE_M, f"{M02}?open=도면&root=master&company=3"], CALC_SITES, ] # 회사가 없으면 해시에 안 실음 - assert _cards({"role": "SYSTEM_ADMIN", "is_master": False})[1][2] == ( + assert _cards({"role": "SYSTEM_ADMIN", "is_master": False})[4][2] == ( f"{M02}?open=구조물조합&root=master" ) +@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") +def test_메뉴_페이지_둘만_역할별(): + # 716-2 — ☰ = M01 · M02 두 페이지(시스템 관리자) / M02 한 페이지(그 밖) · 열쇠 m01 · m02 + admin = _pages({"role": "SYSTEM_ADMIN", "is_master": False, "company_id": 1}, 3) + assert admin == [ + ["m01", "B01_Dashboard_MasterElements", M01], + ["m02", "M02_Title", f"{M02}?open=구조물조합&root=personal&company=3"], + ] + assert _pages({"role": "ADMIN", "is_master": False, "company_id": None}) == [ + ["m02", "M02_Title", f"{M02}?open=구조물조합&root=company"], + ] + assert _pages({"role": "USER", "is_master": False, "company_id": 1}) == [ + ["m02", "M02_Title", f"{M02}?open=구조물조합&root=personal"], + ] + + @pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") def test_회사_관리자_개인_카드_하나(): company = [ @@ -191,14 +239,12 @@ def test_회사_찾기_거르기(): def test_구조물_조합_기본_층_개인(): """123-1 — 회사 있는 사람은 역할 무관 구조물 조합 = 개인 · 회사 없으면 지금 규칙 · 다른 곳 그대로 · 134-4 — 층 없는 주소도 개인(회사 없으면 마스터) · 사전조합 기본 층도 같은 규칙.""" - m = "B01_Dashboard_PermMasterWrite" combo = f"{M02}?open=구조물조합&root=personal" - assert _cards({"role": "SYSTEM_ADMIN", "is_master": False, "company_id": 1}, 3) == [ - ["B01_Dashboard_MasterElements", "B01_Dashboard_PermSourceRead", "m01-master-data"], - ["B01_Dashboard_CardStructCombo", m, f"{combo}&company=3"], - ["B01_Dashboard_CardStructOption", m, f"{M02}?open=옵션&root=master&company=3"], - ["B01_Dashboard_CardDrawing", m, f"{M02}?open=도면&root=master&company=3"], - CALC_SITES, + rows = _cards({"role": "SYSTEM_ADMIN", "is_master": False, "company_id": 1}, 3) + assert rows[3:6] == [ + ["B01_Dashboard_CardPrecombo", WRITE_M, f"{M02}?open=일위대가조합&root=personal&company=3"], + ["B01_Dashboard_CardStructCombo", WRITE_M, f"{combo}&company=3"], + ["B01_Dashboard_CardDrawing", WRITE_M, f"{M02}?open=도면&root=master&company=3"], ] assert _cards({"role": "ADMIN", "is_master": False, "company_id": 1}) == [ ["B01_Dashboard_CardStructCombo", WRITE_CO, combo], @@ -206,7 +252,7 @@ def test_구조물_조합_기본_층_개인(): assert _cards({"role": "USER", "is_master": True, "company_id": 1})[0][2] == combo assert _cards({"role": "USER", "is_master": False, "company_id": 1})[0][2] == combo # 개인 층 없음(회사 없음) = 지금 규칙 - assert _cards({"role": "SYSTEM_ADMIN", "is_master": False, "company_id": None})[1][2] == ( + assert _cards({"role": "SYSTEM_ADMIN", "is_master": False, "company_id": None})[4][2] == ( f"{M02}?open=구조물조합&root=master" ) assert _cards({"role": "ADMIN", "is_master": False, "company_id": None})[0][2] == ( From 28e9a2a6674a3eccba71f7f171aaf5430a789965 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 14:50:02 +0900 Subject: [PATCH 5/5] =?UTF-8?q?feat(718-2):=20=EB=B3=84=EC=B9=AD=20?= =?UTF-8?q?=EC=9E=A5=EB=B6=80=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EB=AA=A8?= =?UTF-8?q?=EC=9C=BC=EA=B8=B0=20=EC=84=9C=EB=B2=84=20=E2=80=94=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=EB=A7=9E?= =?UTF-8?q?=EC=B6=A4=20+=20=EA=B3=B5=EC=82=AC=20=EB=82=B4=EC=97=AD?= =?UTF-8?q?=EB=A7=9E=EC=B6=A4=20=ED=9B=84=EB=B3=B4=20=C2=B7=20=EC=9D=B5?= =?UTF-8?q?=EB=AA=85=20=C2=B7=20=EC=B6=A9=EB=8F=8C=20=C2=B7=20=EC=8A=B9?= =?UTF-8?q?=EC=9D=B8=20=E2=86=92=20=EB=A7=88=EC=8A=A4=ED=84=B0=20=C2=B7=20?= =?UTF-8?q?=EB=B0=98=EB=A0=A4=20=EA=B8=B0=EC=96=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 M01_MasterData_Alias(/api/m01/alias-ledger · 시스템 관리자만 · main.py 한 줄) — 공유 storage 읽기만 · 같은 짝 묶음(서명) · 회사 수 · 사람 수만 · 같은 열쇠 다른 짝 · 마스터와 다름 = 충돌 · 승인 = 서버가 다시 모아 찾음 → 맞춤 ledger.put 재검사 · 내역맞춤 마스터 기억 · 반려 = 마스터 층 별칭반려 기억 · 자동 반영 없음 - B09 잇기 기억 읽기에 마스터 층을 맨 아래로(브레인 허락) · test_707_4 기억 층 시험에 마스터 줄 한 줄 - 새 시험 test_718_2_alias_gather(임시 storage — 모음 수 · 익명 · 충돌 · 승인 → 마스터 줄 · 다른 회사 새 사용자가 씀 · 반려 기억 · 권한) Co-Authored-By: Claude Opus 5.5 --- B09_Estimation/B09_Estimation_Router_Link.py | 5 +- M01_MasterData/M01_MasterData_Alias.py | 215 +++++++++++++++++++ main.py | 4 + resources/tester/test_707_4_bill_link.py | 6 +- resources/tester/test_718_2_alias_gather.py | 134 ++++++++++++ 5 files changed, 361 insertions(+), 3 deletions(-) create mode 100644 M01_MasterData/M01_MasterData_Alias.py create mode 100644 resources/tester/test_718_2_alias_gather.py diff --git a/B09_Estimation/B09_Estimation_Router_Link.py b/B09_Estimation/B09_Estimation_Router_Link.py index 3f8f05ebf..2e0e5291d 100644 --- a/B09_Estimation/B09_Estimation_Router_Link.py +++ b/B09_Estimation/B09_Estimation_Router_Link.py @@ -32,6 +32,7 @@ from B09_Estimation import B09_Estimation_Store as store from common_util.common_util_auth import verify_session from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store from M02_MasterTemplete import M02_Layer_Access as access +from M02_MasterTemplete import M02_MasterTemplete_Store as m02_store router = APIRouter() PATH = "/{project_id}/estimation/link" @@ -51,8 +52,8 @@ def _folders(session: dict[str, Any]) -> dict[str, tuple[Path, bool]]: def recalled(session: dict[str, Any]) -> dict[str, dict[str, Any]]: - """맞춤 기억 — 회사 위에 개인을 덮음.""" - out: dict[str, dict[str, Any]] = {} + """맞춤 기억 — 마스터(시스템 관리자 승인 · 718-2) 위에 회사 · 그 위에 개인을 덮음.""" + out: dict[str, dict[str, Any]] = dict(hopyo_store.memory(m02_store.FOLDER, linker.MEMORY)) for folder, _write in _folders(session).values(): out.update(hopyo_store.memory(folder, linker.MEMORY)) return out diff --git a/M01_MasterData/M01_MasterData_Alias.py b/M01_MasterData/M01_MasterData_Alias.py new file mode 100644 index 000000000..58d95a9e1 --- /dev/null +++ b/M01_MasterData/M01_MasterData_Alias.py @@ -0,0 +1,215 @@ +"""M01 별칭 장부 시스템 모으기(PLAN 718-2 · 요구서 내역12 4장 23 · 4-1 확정 6). + +모든 회사 · 개인 층의 별칭 장부(`실무자료/맞춤.json` · `M02_Hopyo_Ledger`)와 B09 공사 내역 맞춤 +기억(`실무자료/내역맞춤.json` · 707-4)을 읽어 후보로 모음 — 읽기만(공유 storage 안 씀). +후보 = (종류, 열쇠, 서명) · 서명 = 짝 내용 지문(같은 짝이면 같은 서명) · 회사 수 · 사람 수만 +(누가 · 처음파일 · 층 주인은 안 내보냄 = 익명) · 충돌 = 같은 열쇠 다른 서명 · 마스터와 다름. +승인 = 시스템 관리자만 → 마스터 층에 씀(맞춤 = `ledger.put` 재검사 · 내역맞춤 = 기억 파일) · +반려 = 마스터 층 `실무자료/별칭반려.json` 에 서명 기억(같은 짝은 다시 안 보임) · 자동 반영 없음. +""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field + +from common_util.common_util_auth import verify_session +from common_util.common_util_time import utc_now_text +from M02_MasterTemplete import M02_Hopyo_Ledger as ledger +from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Template_Layers as layers + +ALIAS, BILL = "맞춤", "내역맞춤" +KINDS = (ALIAS, BILL) +REJECT = "별칭반려" + +router = APIRouter(prefix="/api/m01/alias-ledger", tags=["M01 별칭 장부"]) + + +# ── 읽기 ───────────────────────────────────────────────────────────── +def layer_folders() -> list[tuple[str, str | None, Path]]: + """(회사, 사용자 | None, 층 폴더) — storage 의 회사 · 개인 층(숫자 폴더만 · 두 단만 훑음).""" + root = layers.storage_root() + out: list[tuple[str, str | None, Path]] = [] + for c in sorted(root.iterdir() if root.is_dir() else []): + if not (c.is_dir() and c.name.isdigit()): + continue + out.append((c.name, None, layers.company_dir(c.name))) + for u in sorted(c.iterdir()): + if u.is_dir() and u.name.isdigit(): + out.append((c.name, u.name, layers.personal_dir(c.name, u.name))) + return out + + +def _body(kind: str, row: dict[str, Any]) -> dict[str, Any]: + """서명에 드는 내용 — 맞춤 = 갈래 · 짝 · 내역맞춤 = 확정 줄 키 · 이름.""" + if kind == ALIAS: + return {"갈래": row.get("갈래"), "짝": row.get("짝") or []} + return {"확정": row.get("확정"), "이름": row.get("이름")} + + +def sign(kind: str, row: dict[str, Any]) -> str: + raw = json.dumps(_body(kind, row), ensure_ascii=False, sort_keys=True) + return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16] + + +def _book(kind: str, folder: Path) -> dict[str, dict[str, Any]]: + if kind == ALIAS: + return ledger.read(folder) + got = hopyo_store.memory(folder, BILL) + return { + k: v + for k, v in got.items() + if isinstance(v, dict) and v.get("확정") and str(k).count("|") == 2 + } # 잊음 · 깨진 열쇠 뺌 + + +def _origin(full: str, row: dict[str, Any]) -> dict[str, str]: + if isinstance(row.get("원래"), dict): + return {k: str(row["원래"].get(k) or "") for k in ("품명", "규격", "단위")} + return ledger._plain_of(full) + + +def rejected() -> dict[str, dict[str, Any]]: + return hopyo_store.memory(store.FOLDER, REJECT) + + +def _reject_id(kind: str, full: str, sig: str) -> str: + return f"{kind}|{sig}|{full}" + + +def gather() -> dict[str, Any]: + """후보 모음 `{후보, 모은곳{회사, 사람}, 반려}` — 마스터와 같은 짝 · 반려한 짝은 뺌.""" + seen: dict[tuple[str, str, str], dict[str, Any]] = {} + firms, people = set(), set() + for company, user, folder in layer_folders(): + for kind in KINDS: + for full, row in _book(kind, folder).items(): + sig = sign(kind, row) + who = ( + (row.get("누가") or {}).get("id") if isinstance(row.get("누가"), dict) else None + ) + person = f"{company}/{user}" if user else f"{company}/{who or '-'}" + one = seen.setdefault( + (kind, full, sig), + {"종류": kind, "열쇠": full, "서명": sig, "원래": _origin(full, row), + **_body(kind, row), "회사": set(), "사람": set(), "때": None}, + ) # fmt: skip + one["회사"].add(company) + one["사람"].add(person) + firms.add(company) + people.add(person) + stamp = row.get("때") + if stamp and (one["때"] is None or str(stamp) > str(one["때"])): + one["때"] = stamp + master = {kind: _book(kind, Path(store.FOLDER)) for kind in KINDS} + gone = rejected() + live = [] + for (kind, full, sig), one in seen.items(): + mine = master[kind].get(full) + if (mine and sign(kind, mine) == sig) or _reject_id(kind, full, sig) in gone: + continue + one["마스터"] = "다름" if mine else None + live.append(one) + many: dict[tuple[str, str], int] = {} + for one in live: + many[(one["종류"], one["열쇠"])] = many.get((one["종류"], one["열쇠"]), 0) + 1 + out = [] + for one in sorted(live, key=lambda x: (x["종류"], x["열쇠"], -len(x["사람"]), x["서명"])): + clash = many[(one["종류"], one["열쇠"])] > 1 or one["마스터"] == "다름" + out.append({**one, "회사": len(one["회사"]), "사람": len(one["사람"]), "충돌": clash}) + return {"후보": out, "모은곳": {"회사": len(firms), "사람": len(people)}, "반려": len(gone)} + + +def _find(kind: str, full: str, sig: str) -> dict[str, Any]: + if kind not in KINDS: + raise store.StoreError(400, f"종류는 {' · '.join(KINDS)}") + got = next( + (x for x in gather()["후보"] if (x["종류"], x["열쇠"], x["서명"]) == (kind, full, sig)), + None, + ) + if got is None: + raise store.StoreError(404, f"후보가 없음(이미 승인 · 반려 · 바뀜) 「{full}」") + return got + + +# ── 쓰기(마스터 층만) ───────────────────────────────────────────────── +def approve(kind: str, full: str, sig: str, who: Any) -> dict[str, Any]: + """후보 하나 → 마스터 장부(서버가 다시 모아 찾음 · 맞춤은 `ledger.put` 재검사).""" + one = _find(kind, full, sig) + if kind == ALIAS: + ledger.put(store.FOLDER, {full: {"원래": one["원래"], **_body(kind, one)}}, who) + else: + row = {**_body(kind, one), "누가": who, "때": utc_now_text()} + hopyo_store.remember(store.FOLDER, {full: row}, BILL) + return gather() + + +def reject(kind: str, full: str, sig: str, who: Any) -> dict[str, Any]: + _find(kind, full, sig) + row = {"종류": kind, "열쇠": full, "서명": sig, "누가": who, "때": utc_now_text()} + hopyo_store.remember(store.FOLDER, {_reject_id(kind, full, sig): row}, REJECT) + return gather() + + +def master_bill() -> list[dict[str, Any]]: + """마스터 내역맞춤 줄(보기 · 지움) — 맞춤 장부는 `/api/m02/layers/system/hopyo-ledger`.""" + book = _book(BILL, Path(store.FOLDER)) + return [ + {"열쇠": k, "원래": ledger._plain_of(k), **_body(BILL, v)} for k, v in sorted(book.items()) + ] + + +def forget_bill(full: str) -> list[dict[str, Any]]: + if full not in _book(BILL, Path(store.FOLDER)): + raise store.StoreError(404, f"마스터 내역맞춤에 없음 「{full}」") + hopyo_store.remember(store.FOLDER, {full: None}, BILL) + return master_bill() + + +# ── 길(시스템 관리자만 — main.py 에서 system_admin_only) ─────────────── +def _who(session: dict[str, Any]) -> dict[str, Any]: + return {"id": session.get("user_id"), "이름": session.get("name")} + + +def _call(fn, *args): + try: + return fn(*args) + except store.StoreError as error: + raise HTTPException(error.status, error.detail) from error + + +class Pick(BaseModel): + 종류: str = Field(max_length=10) + 열쇠: str = Field(min_length=1, max_length=400) + 서명: str = Field(min_length=16, max_length=16) + + +@router.get("") +def candidates() -> dict[str, Any]: + return {**gather(), "마스터내역": master_bill()} + + +@router.post("/approve") +def approve_one(body: Pick, session: dict = Depends(verify_session)) -> dict[str, Any]: + return _call(approve, body.종류, body.열쇠, body.서명, _who(session)) + + +@router.post("/reject") +def reject_one(body: Pick, session: dict = Depends(verify_session)) -> dict[str, Any]: + return _call(reject, body.종류, body.열쇠, body.서명, _who(session)) + + +class Forget(BaseModel): + 열쇠: str = Field(min_length=1, max_length=400) + + +@router.post("/master-bill/forget") +def forget_one(body: Forget) -> dict[str, Any]: + return {"마스터내역": _call(forget_bill, body.열쇠)} diff --git a/main.py b/main.py index cdc6beb4a..0209fd0a9 100644 --- a/main.py +++ b/main.py @@ -544,6 +544,10 @@ app.include_router(b09_estimation_router, dependencies=protected_with_company) # 마스터 데이터 — 회사·프로젝트가 아니라 시스템 관리자만 본다. system_admin_only = [Depends(verify_session), Depends(require_system_admin)] app.include_router(m01_master_data_router, dependencies=system_admin_only) +app.include_router( + __import__("M01_MasterData.M01_MasterData_Alias", fromlist=["router"]).router, + dependencies=system_admin_only, +) # 별칭 장부 모으기(718-2) # 단가산출 흐름도 시험 길 — 폴더째 지우면 이 줄도 지움 app.include_router( __import__("M02_MasterTemplete.DataFlow.M02_DF_Router", fromlist=["router"]).router, diff --git a/resources/tester/test_707_4_bill_link.py b/resources/tester/test_707_4_bill_link.py index 5f324d209..8dcf30126 100644 --- a/resources/tester/test_707_4_bill_link.py +++ b/resources/tester/test_707_4_bill_link.py @@ -211,6 +211,9 @@ def test_못_잇는_짝(geo): ) def test_기억_층(tmp_path, monkeypatch, role, layer): monkeypatch.setattr(route.access, "folder", lambda got, create=False: tmp_path / got.layer) + monkeypatch.setattr(route.m02_store, "FOLDER", tmp_path / "system") + master = {"a||": {"확정": "earth:9"}, "m||": {"확정": "earth:1"}} # 시스템 승인(718-2) + hopyo_store.remember(tmp_path / "system", master, lk.MEMORY) session = {"company_id": 7, "user_id": 3, "role": role} assert route.keep(session, {"a||": {"확정": "earth:4", "이름": "절토(토사)"}}) == layer assert route.keep(session, {}) is None @@ -220,7 +223,8 @@ def test_기억_층(tmp_path, monkeypatch, role, layer): got = route.recalled(session) assert got["a||"]["확정"] == ("earth:4" if layer == "personal" else "earth:5") assert got["b||"]["확정"] == "x" - assert route.recalled({"user_id": 3, "role": role}) == {} # 회사 없음 = 층 없음 + assert got["m||"]["확정"] == "earth:1" # 회사 · 개인 줄이 마스터보다 앞섬(a = 아래 층) + assert route.recalled({"user_id": 3, "role": role}) == master # 회사 없음 = 마스터만 def _same(a, b): diff --git a/resources/tester/test_718_2_alias_gather.py b/resources/tester/test_718_2_alias_gather.py new file mode 100644 index 000000000..97d1c250c --- /dev/null +++ b/resources/tester/test_718_2_alias_gather.py @@ -0,0 +1,134 @@ +"""별칭 장부 시스템 모으기(PLAN 718-2 · `M01_MasterData_Alias`) — 회사 · 개인 층 맞춤 + +내역맞춤 모음 · 같은 짝 묶기 · 회사 수 · 사람 수(익명) · 충돌 · 승인 → 마스터 줄 · +반려 기억 · 다른 회사 새 사용자가 마스터 줄을 씀 +· 시스템 관리자만 · master_data 원본 그대로. 층 · storage 는 임시 폴더. +""" + +import json +from pathlib import Path + +import pytest +from fastapi import Depends, FastAPI +from fastapi.testclient import TestClient + +from B09_Estimation import B09_Estimation_Bill_Link as lk +from B09_Estimation import B09_Estimation_Router_Link as route +from common_util.common_util_auth import require_system_admin, verify_session +from M01_MasterData import M01_MasterData_Alias as alias +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 client, master_hash # noqa: F401 + +DIG = {"이름": "구조물터파기(인력100%)", "규격": "토사", "단위": "㎥"} +DIG_KEY = "구조물터파기(인력100%)|토사|m3" +FORM_KEY = "합판거푸집|4회|m2" +CUT_KEY = "토사절취||m3" +ROOT = Path(__file__).resolve().parents[2] + + +def _pair(code): + return ledger.check(DIG_KEY, {"갈래": "짝", "짝": [{"로직": code, "배": 1}]}) + + +def _seed(): + """회사 7(개인 42 · 43 = 같은 짝) · 회사 8(개인 50 = 다른 짝 · 회사 층 = 안 씀) · 내역맞춤.""" + for user, who in ((42, "갑"), (43, "을")): + mine = layers.personal_dir(7, user) + ledger.write(mine, {DIG_KEY: _pair("GF000101")}, {"id": user, "이름": who}, "가공사.xls") + ledger.write(layers.personal_dir(8, 50), {DIG_KEY: _pair("GF000103")}, {"id": 50}, "나.xls") + skip = ledger.check(FORM_KEY, {"갈래": "안 씀"}) + ledger.write(layers.company_dir(8), {FORM_KEY: skip}, {"id": 51, "이름": "병"}, "다.xls") + live = {"확정": "earth:4", "이름": "절토(토사)"} + hs.remember(layers.personal_dir(7, 42), {CUT_KEY: live, "a||": {lk.FORGOT: True}}, lk.MEMORY) + hs.remember(layers.company_dir(8), {CUT_KEY: live}, lk.MEMORY) + + +def _one(got, kind, key, code=None): + rows = [x for x in got["후보"] if (x["종류"], x["열쇠"]) == (kind, key)] + if code: + rows = [x for x in rows if x["짝"][0]["로직"] == code] + assert len(rows) == 1, rows + return rows[0] + + +def test_모음_묶기_익명_충돌(client): + before = master_hash() + _seed() + got = alias.gather() + assert got["모은곳"] == {"회사": 2, "사람": 5} and got["반려"] == 0 + assert len(got["후보"]) == 4 # 터파기 둘(충돌) · 거푸집 안 씀 · 토사절취 내역맞춤(같은 짝 하나) + same = _one(got, alias.ALIAS, DIG_KEY, "GF000101") + assert (same["회사"], same["사람"], same["충돌"], same["마스터"]) == (1, 2, True, None) + assert _one(got, alias.ALIAS, DIG_KEY, "GF000103")["사람"] == 1 + form = _one(got, alias.ALIAS, FORM_KEY) + assert (form["갈래"], form["충돌"], form["사람"]) == ("안 씀", False, 1) + cut = _one(got, alias.BILL, CUT_KEY) + assert (cut["회사"], cut["사람"], cut["확정"], cut["충돌"]) == (2, 2, "earth:4", False) + text = json.dumps(got, ensure_ascii=False) + for secret in ("갑", "을", "병", "가공사.xls", "누가", "처음파일", "주인"): + assert secret not in text # 익명 — 회사 수 · 사람 수만 + assert master_hash() == before + + +def test_승인_마스터_줄_반려_기억(client, tmp_path, monkeypatch): + before = master_hash() + _seed() + newbie = layers.personal_dir(9, 99) # 처음 쓰는 다른 회사 사용자 + assert ledger.recall(DIG, newbie) is None + sig = _one(alias.gather(), alias.ALIAS, DIG_KEY, "GF000101")["서명"] + got = alias.approve(alias.ALIAS, DIG_KEY, sig, {"id": 1, "이름": "관리자"}) + hit = ledger.recall(DIG, newbie) + assert (hit["층"], hit["짝"][0]["로직"]) == ("system", "GF000101") # 승인 → 모두가 씀 + master = ledger.read(client.system) + assert master[DIG_KEY]["처음파일"] is None # 마스터 줄에도 남의 파일 이름 없음 + other = _one(got, alias.ALIAS, DIG_KEY) # 승인한 짝 = 마스터와 같음 → 후보에서 빠짐 + assert other["짝"][0]["로직"] == "GF000103" + assert (other["마스터"], other["충돌"]) == ("다름", True) + got = alias.reject(alias.ALIAS, DIG_KEY, other["서명"], {"id": 1}) + assert got["반려"] == 1 and not [x for x in got["후보"] if x["열쇠"] == DIG_KEY] + assert alias.gather()["반려"] == 1 # 반려 기억 = 다시 모아도 안 보임 + with pytest.raises(alias.store.StoreError) as error: + alias.approve(alias.ALIAS, DIG_KEY, other["서명"], {"id": 1}) + assert error.value.status == 404 + with pytest.raises(alias.store.StoreError): + alias.approve("모름", DIG_KEY, sig, {"id": 1}) + # 내역맞춤 승인 → 마스터 기억 → 다른 회사 새 사용자 B09 잇기 기억에 나옴 + cut = _one(alias.gather(), alias.BILL, CUT_KEY) + alias.approve(alias.BILL, CUT_KEY, cut["서명"], {"id": 1}) + assert [r["열쇠"] for r in alias.master_bill()] == [CUT_KEY] + monkeypatch.setattr(route.access, "folder", lambda got, create=False: tmp_path / got.layer) + user = {"company_id": 9, "user_id": 99, "role": "USER"} + assert route.recalled(user)[CUT_KEY]["확정"] == "earth:4" + assert alias.forget_bill(CUT_KEY) == [] and CUT_KEY not in route.recalled(user) + assert master_hash() == before + + +def test_길_시스템_관리자만(client): + _seed() + app = FastAPI() + app.include_router(alias.router, dependencies=[Depends(require_system_admin)]) + who = {"user_id": 1, "company_id": None, "role": "USER", "name": "갑"} + app.dependency_overrides[verify_session] = lambda: who + web = TestClient(app) + for role in ("USER", "ADMIN"): + who["role"] = role + assert web.get("/api/m01/alias-ledger").status_code == 403 + who["role"] = "SYSTEM_ADMIN" + got = web.get("/api/m01/alias-ledger").json() + assert len(got["후보"]) == 4 and got["마스터내역"] == [] + form = _one(got, alias.ALIAS, FORM_KEY) + pick = {"종류": alias.ALIAS, "열쇠": FORM_KEY, "서명": form["서명"]} + res = web.post("/api/m01/alias-ledger/approve", json=pick) + assert res.status_code == 200 and len(res.json()["후보"]) == 3 + assert ledger.read(client.system)[FORM_KEY]["갈래"] == "안 씀" + assert web.post("/api/m01/alias-ledger/approve", json=pick).status_code == 404 + assert web.post("/api/m01/alias-ledger/reject", json={**pick, "서명": "x"}).status_code == 422 + gone = web.post("/api/m01/alias-ledger/master-bill/forget", json={"열쇠": CUT_KEY}) + assert gone.status_code == 404 + main = (ROOT / "main.py").read_text(encoding="utf-8") + at = main.index("M01_MasterData.M01_MasterData_Alias") + assert "dependencies=system_admin_only" in main[at : at + 120]