From 3a2bef58db96543f92e9a0866163b42ee98547e0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 14:44:25 +0900 Subject: [PATCH 1/2] =?UTF-8?q?chore(=EC=9A=94=EA=B5=AC):=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=EC=9A=94=EA=B5=AC=5F1010=5F2=20=EA=B3=B5?= =?UTF-8?q?=EC=9C=A0=EB=B3=B8=2065~66=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QND7Co43B24zK2P937rdxt --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index 9cd35f849..65e94afc6 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -73,3 +73,17 @@ 50. 대시보드의 구조물옵션 정의가 제대로 연결안됨. 51. B08의 구조물 집계표에서 미연결 상태표시는 행의 좌측에 하나의 램프로 표기, 미지정 열을 필요없어 보임. 52. B08의 페이지들을 보면 합계가 나오는 행들을 보면 초기에는 행들이 추가되다가 특정 컨테이너 높이가 되면 합계 행을 고정하고 내부를 스크롤 방식으로 변경하고 있음. 이방법 자체는 좋은데 그렇다면 컨테이너의 높이제약을 현재 브라우져가 스크롤이 안되는 지점을 기준으로 적용하는게 조작 편의성이 좋아보임. 그럼 행들이 추가됨에 따라 밀리더라도 스크롤은 페이지스크롤은 없을테니 +53. B09의 박스보기 버튼과 가로 숫자 버튼, 그리고 그위의 계산들에 대한 안내는 같은행으로 변경 +54. 박스보기의 경우 지정된 카드 행들은 높이를 같게 변경 +55. 설계내역서의 공종 및 명칭의 글자들은 좌측 맞춤이고 좌측에 들여쓰기로 레벨이 표현되는게 맞아보임. 그리고 레벨 제어를 어떻게 하는지 모르겠음. +56. 박스보기에서 제목과 합계 정보열의 색은 다른색으로 지정하는게 테마고려하여 선택 +57. B08/B09의 테이블 양식이 에서 레벨이 필요한 정보들은 설계내역서의 레벨지정 양식을 따라가되 제어가 안되게 반영하면 일원화되지 않을까? 그리고 셀에 같은 값으로 표현되는 행들은 결국 병합인것같은데 맞지 않아? +58. 제비율표는 일력하는 공사원가계산서와 총괄설계내역서의 내용에서 요율을 원문 제비율표를 보고 누락이 된게 없는지 봐줘. (조사만) +59. 현재 B08/B09의 테이블 컨테이너의 양식을 보면 제일상단에 경고줄이 있어 이걸제일하단으로 옮겨줘. 이때 박스보기와 가로 카드수 정의는 보조탭 행에 우측맞춤으로 이동 +60. 다음으로 B09의 좌측패널의 원가 단가 컨테이너의 이름을 데이터 점검으로 이름을 변경하고 내부의 쓸대없는 설명은 삭제, 단가 갱신버튼의 제목을 업데이트, 업데이트가 필요한경우 버튼에 오버레이 마크 추가, 상세 페이지의 연결점검 탭은 데이터 점검 컨테이너 내부로 이동 +61. B09의 설계내역서에서 행들을 레벨이나 이동을 드래그나 키보드의 방향키로 제어가 되어야함. 구현하고 결국 내어쓰기와 들여쓰기는 결국 레벨이 되어야함. 상위 레벨로 이동이 되면 자연스럽게 하위항목의 합계가 세부항목이 되면 상위로 데이터를 전달하는 역할을 그리고 만약 기존 상위에 있던것들이 하위로 이동할경우에는 같이 이동되는 기능도 있어야함. 다음으로 일위대가와 단가산출의 정보를 연결할때 중요한건 마스터/회사/개인의 정보중 구조물조합, 일위대가사전조합, 현재처럼 벌써 들어와 있는 일위대가와 호표를 지정할 수도 있어야함. +62. B09의 좌측 패널의 컨테이너를 공용양식을 사용한건지 확인 (확인만) +63. B05/B06의 구조물배치 컨테이너에서 삭제버튼과 리셋버튼이 높이사이즈를 하위 컨테이너의 버튼 높이와 동일하게 구현하고 내부 컨테이너에서 삭제와 리셋버튼이 항상보이게 구현 +64. [사진] 현재 검증 서브1 프로젝트의 1+0 측점의 내용임. 이때 원래는 측구가 없어야하는 성토사면에 존재함. 이때 측구버튼과 일반L형 버튼이 없음. 숨기기 결과로 보임. 이런 현상의 원인이 궁금함. 한번 좌우를 바꾸면 재현안됨. 확실히 조사하여 반영할것. (사진: 1+0 · 양측 성토 · 성토사면 좌 18.61m · 우 0.26m · 좌|우 단추 중 「우」 켜짐 · 오른쪽 노견 바깥에 작은 측구 모양 · 측구 · 일반/L형 단추 안 보임) +65. B06의 상단의 성토 5m 이상, 포장 권장, 소단 검토에서 버튼의 형상을 사각 라운드로 변경하고 누를시 나오는 버튼들은 현재를 유지, 그리고 메인 페이지의 스크롤이 제일 상단에 있을경우 상단 버튼으로 내역을 열면 페이지가 아래로 밀림. 안밀리게 변경, 그리고 항목을 선택하면 이동하면서 해당 버튼셋은 접힘으로 그리고 만약 선택된 상태에서 다시 열면 하이라이트 기능, 다시 말해 사용자가 측점 횡단도를 누른상태에서 상단 3버튼을 누르면 하이라이트 되게 변경 +66. M01의 단가산출로직들의 항목에 신규열인 별칭 열이 필요함 설계자들이 찾을때 사용하는 명칭과 다른 경우가 있음. 이를 보완하기 위해 별칭열을 추가해줘. 내가 별칭을 있을수도 있고 없을 수도 있지만 값은 내가 넣을거야. 그리고 이때 프로그램 전역에서 단가산출 관련 검색은 별칭도 같이 검색이 되게 해야함. From f1b22087655b34f1989732dea94d6861a1a2849b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 15:11:01 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(M01):=20=EB=8B=A8=EA=B0=80=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=EB=A1=9C=EC=A7=81=20=EB=B3=84=EC=B9=AD=20=EC=B9=B8?= =?UTF-8?q?=20=C2=B7=20=EB=A1=9C=EC=A7=81=20=EC=B0=BE=EA=B8=B0=20=EA=B3=B5?= =?UTF-8?q?=EC=9A=A9=20=EB=A7=9E=EC=B6=A4=EC=97=90=20=EB=B3=84=EC=B9=AD=20?= =?UTF-8?q?=ED=8F=AC=ED=95=A8(208-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QND7Co43B24zK2P937rdxt --- M01_MasterData/M01_MasterData_Store.py | 15 +- M01_MasterData/M01_MasterData_Store_Find.py | 20 ++ M01_MasterData/M01_MasterData_UI_LogicLab.ts | 30 ++- M01_MasterData/M01_MasterData_UI_Logic_Api.ts | 6 + .../M01_MasterData_UI_Logic_List.ts | 72 +++++-- .../M01_MasterData_UI_Logic_Pick.ts | 14 +- .../M01_MasterData_UI_Logic_Style.css | 17 ++ .../M01_MasterData_UI_Logic_Text.ts | 3 +- .../DataFlow/M02_DF_InputPanel_Build.ts | 9 +- .../DataFlow/M02_DF_InputPanel_Logic.ts | 6 + .../M02_MasterTemplete_Labor_Pick.ts | 7 +- resources/tester/test_208_1_logic_alias.py | 185 ++++++++++++++++++ resources/tester/test_m02_df_input_logic.py | 17 +- ui_template/ui_template_logic_find.ts | 53 +++++ 14 files changed, 411 insertions(+), 43 deletions(-) create mode 100644 M01_MasterData/M01_MasterData_Store_Find.py create mode 100644 resources/tester/test_208_1_logic_alias.py create mode 100644 ui_template/ui_template_logic_find.ts diff --git a/M01_MasterData/M01_MasterData_Store.py b/M01_MasterData/M01_MasterData_Store.py index 0e95c8319..8cb590c58 100644 --- a/M01_MasterData/M01_MasterData_Store.py +++ b/M01_MasterData/M01_MasterData_Store.py @@ -26,6 +26,8 @@ import master_keys as mk # noqa: E402 import master_text as mtx # noqa: E402 from M01_MasterData import M01_MasterData_Store_Cache as cache # noqa: E402 +from M01_MasterData.M01_MasterData_Store_Find import hit as _hit # noqa: E402 +from M01_MasterData.M01_MasterData_Store_Find import squash as _squash # noqa: E402 from M01_MasterData.M01_MasterData_Store_Shape import dump, keep_shape # noqa: E402 _COPY = os.environ.get("AISLO_MASTER_DIR") # 화면 저장 시험 사본 폴더(정함 13) · 없으면 정본 @@ -88,11 +90,6 @@ def chapter_of(file: str, data: dict | None = None) -> str: return f"{division} {m.group(1)}장 {m.group(2).replace('_', ' ')}".strip() -def _hit(row: dict, q: str) -> bool: - q = _squash(q) # 띄어쓰기 무시 — 「골 함 석」 ↔ 「함석」 - return not q or any(q in _squash(row.get(k, "")) for k in ("키", "원문번호", "이름", "옛이름")) - - def _book(data: dict) -> str | None: """머리 원문 이름 — 한 테이블 파일(기계)은 머리 「원문」 이 갈래 묶음이라 이름이 묶음 안에.""" return data.get("원문") if isinstance(data.get("원문"), str) else None @@ -367,6 +364,7 @@ def logics(sub: str, detail: str, q: str, blocked: int | None, owner: str = "") "소유", ) }, + "별칭": row.get("별칭") or "", # 없으면 빈칸(208-1) "blocked": bool(reasons), "reasons": reasons, } @@ -449,17 +447,14 @@ def elements(group: str, q: str, limit: int) -> dict: continue for row in data.get(items_key(data)) or []: if _hit(row, q): - hits.append({**_brief(str(row.get("키")), row), "file": name}) + more = {"별칭": row.get("별칭") or ""} if group == "로직" else {} # 208-1 + hits.append({**_brief(str(row.get("키")), row), "file": name, **more}) return {"total": len(hits), "items": hits[: min(max(limit, 1), 200)]} _PICK = {"price": "재료_자재품목.json", "job": "인력.json"} -def _squash(text) -> str: - return "".join(str(text or "").lower().split()) - - def _lowest(row: dict) -> object: """자재품목 줄 — 값 열 다섯 가운데 낮은 값(시험 계산용) · 값 칸이 있는 줄은 그 값 그대로.""" if "값" in row: diff --git a/M01_MasterData/M01_MasterData_Store_Find.py b/M01_MasterData/M01_MasterData_Store_Find.py new file mode 100644 index 000000000..47b64ceba --- /dev/null +++ b/M01_MasterData/M01_MasterData_Store_Find.py @@ -0,0 +1,20 @@ +"""찾기 맞춤 — 낱말(띄어 나눔)마다 키 · 원문번호 · 이름 · 옛이름 · 별칭 가운데 하나에 들면 맞음. + +띄어쓰기 · 대소문자 무시 · 별칭 = 설계자가 로직을 찾는 다른 이름(쉼표로 여럿 · 208-1). +짝: `ui_template/ui_template_logic_find.ts` `matchLogic`(화면 쪽) · +거울 시험 `test_208_1_logic_alias.py`. +""" + +from __future__ import annotations + +FIELDS = ("키", "원문번호", "이름", "옛이름", "별칭") + + +def squash(text) -> str: + return "".join(str(text or "").lower().split()) + + +def hit(row: dict, q: str) -> bool: + words = [squash(w) for w in str(q or "").split()] + fields = [squash(row.get(k)) for k in FIELDS] + return all(any(w in f for f in fields) for w in words) diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab.ts b/M01_MasterData/M01_MasterData_UI_LogicLab.ts index 2dd75b26c..123b3ec9d 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab.ts @@ -135,7 +135,10 @@ export async function mountM01LogicLab( const waiting = el("p", { className: "m01-logic__muted", text: tx("Loading") }); let loaded = false; // 첫 목록이 오기 전에는 「0 줄」 빈 표를 보이지 않음 const pending = el("span", { className: "m01-logic__muted" }); - const list = buildList((item) => void open(item.id, item.sub, item.key)); + const list = buildList( + (item) => void open(item.id, item.sub, item.key), + (item, alias) => void onAlias(item, alias), + ); const back = createButton({ label: tx("Bar_Back"), variant: "ghost", onClick: () => show(null) }); const persist = (): void => { @@ -146,8 +149,10 @@ export async function mountM01LogicLab( } const marks = new Map(); const extra: ListItem[] = []; + const aliases = new Map(); for (const [id, d] of Object.entries(drafts)) { marks.set(id, d.origKey === null ? "new" : d.row === null ? "deleted" : "edited"); + if (d.row) aliases.set(id, d.row.별칭 ?? ""); if (d.origKey === null && d.row) { extra.push({ id, @@ -156,6 +161,7 @@ export async function mountM01LogicLab( key: d.row.키, number: d.row.원문번호, name: d.row.이름, + alias: d.row.별칭 ?? "", unit: d.row.결과단위, source: d.row.출처, blocked: false, @@ -163,7 +169,7 @@ export async function mountM01LogicLab( }); } } - list.setMarks(marks, extra); + list.setMarks(marks, extra, aliases); const n = Object.keys(drafts).length; pending.textContent = n ? tx("Bar_Pending", { n }) : ""; saveButton.disabled = discardButton.disabled = n === 0; @@ -423,6 +429,26 @@ export async function mountM01LogicLab( persist(); }; + /** 목록 「별칭」 칸 고침(208-1) — 그 줄의 별칭만 바꿔 캐시에 · [저장] 때 다른 고침과 함께 나감 */ + const onAlias = async (item: ListItem, alias: string): Promise => { + const draft = drafts[item.id]; + if (draft && !draft.row) return; // 지운 줄 + try { + const one = draft?.origKey === null ? null : await fetchLogic(item.key); + const row = clone((draft?.row ?? one?.logic) as LogicRow); + if (alias) row.별칭 = alias; + else delete row.별칭; + if (one && JSON.stringify(row) === JSON.stringify(one.logic)) delete drafts[item.id]; + else + drafts[item.id] = draft + ? { ...draft, row } + : { sub: item.sub, file: one!.file, version: one!.version, origKey: item.key, row }; + persist(); + } catch (error) { + showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error"); + } + }; + const onDelete = async (): Promise => { if (!opened?.row) return; if ( diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Api.ts b/M01_MasterData/M01_MasterData_UI_Logic_Api.ts index 0dcc8e932..947a8a044 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Api.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Api.ts @@ -42,6 +42,8 @@ export interface LogicRow { 덧줄?: NamedFormula[]; 끝수?: string | null; 비고?: string; + /** 설계자가 찾는 다른 이름 — 쉼표로 여럿 · 없으면 칸 없음(208-1) */ + 별칭?: string; [extra: string]: unknown; } @@ -54,6 +56,8 @@ export interface LogicSummary { 이름: string; 결과단위: string; 출처: string; + /** 없으면 빈칸(208-1) */ + 별칭?: string; blocked: boolean; reasons: string[]; } @@ -66,6 +70,8 @@ export interface ElementBrief { 단위?: unknown; 값?: unknown; 값칸?: Record | null; + /** 로직 그룹만 — 별칭(208-1) */ + 별칭?: string; /** 재료 후보 줄 — 값 열 다섯(지역·계약종별별 값) */ 값들?: Record; } diff --git a/M01_MasterData/M01_MasterData_UI_Logic_List.ts b/M01_MasterData/M01_MasterData_UI_Logic_List.ts index d22b7ecde..cd9a4931d 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_List.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_List.ts @@ -2,9 +2,11 @@ * M01_MasterData_UI_Logic_List.ts * 로직 목록 — 가운데 표 · 왼쪽 「전체」 + 구분 → 상세구분 거름이 고른 범위 + 이름 찾기 * 파일을 가로질러 한 목록 · 쪽 나눔 · 막힘(⛔)·고침(●) 표시 · 거르기는 화면에서(목록은 한 번 받음) + * 「별칭」 열(208-1) — 이름 옆 · 고치면 `onAlias`(캐시에 쌓고 [저장] 때 나감) · 찾기는 공용 `matchLogic` * ========================================================================== */ import { createInputField, el } from "@ui/ui_template_elements"; +import { aliasText, matchLogic } from "@ui/ui_template_logic_find"; import { buildPager } from "./M01_MasterData_UI_Cells"; import type { LogicSummary } from "./M01_MasterData_UI_Logic_Api"; import { tx } from "./M01_MasterData_UI_Logic_Text"; @@ -19,6 +21,8 @@ export interface ListItem { key: string; number: string; name: string; + /** 쉼표로 여럿 · 없으면 빈칸 */ + alias: string; unit: string; source: string; blocked: boolean; @@ -28,8 +32,8 @@ export interface ListItem { export interface ListHandle { root: HTMLElement; setItems: (items: LogicSummary[]) => void; - /** 저장 안 한 새 로직도 목록 맨 위에 */ - setMarks: (marks: Map, extra: ListItem[]) => void; + /** 저장 안 한 새 로직도 목록 맨 위에 · aliases = 캐시에 고친 별칭(id → 글) */ + setMarks: (marks: Map, extra: ListItem[], aliases: Map) => void; /** 왼쪽 거름이 고른 범위 — 비면 전체 */ setPick: (sub: string, detail: string) => void; total: () => number; @@ -38,12 +42,27 @@ export interface ListHandle { export const logicId = (sub: string, key: string): string => `${sub}\n${key}`; const SIZE = 30; -const COLS = ["키", "원문번호", "구분", "상세구분", "이름", "결과 단위", "출처", ""] as const; +const COLS = [ + "키", + "원문번호", + "구분", + "상세구분", + "이름", + "별칭", + "결과 단위", + "출처", + "", +] as const; -export function buildList(onOpen: (item: ListItem) => void): ListHandle { +export function buildList( + onOpen: (item: ListItem) => void, + onAlias: (item: ListItem, alias: string) => void, +): ListHandle { let items: ListItem[] = []; let extra: ListItem[] = []; let marks = new Map(); + let aliases = new Map(); + const aliasOf = (x: ListItem): string => aliases.get(x.id) ?? x.alias; let page = 1; const pick = { sub: "", detail: "" }; const searchField = createInputField({ type: "search", placeholder: tx("List_Search") }); @@ -51,12 +70,19 @@ export function buildList(onOpen: (item: ListItem) => void): ListHandle { const grid = el("div", { className: "m01-master__grid-wrap" }); const draw = (): void => { - const q = search.value.trim().toLowerCase(); + const q = search.value; const shown = [...extra, ...items].filter( (x) => (!pick.sub || x.sub === pick.sub) && (!pick.detail || x.detail === pick.detail) && - (!q || [x.key, x.number, x.name].some((v) => v.toLowerCase().includes(q))), + matchLogic(q, { + 키: x.key, + 원문번호: x.number, + 이름: x.name, + 구분: x.sub, + 상세구분: x.detail, + 별칭: aliasOf(x), + }).ok, ); page = Math.min(page, Math.max(1, Math.ceil(shown.length / SIZE))); const rows = shown.slice((page - 1) * SIZE, page * SIZE).map(line); @@ -87,24 +113,36 @@ export function buildList(onOpen: (item: ListItem) => void): ListHandle { ] .filter(Boolean) .join(" "); + const cell = (v: string): HTMLElement => el("td", { text: v, attrs: { title: v } }); const tr = el("tr", { className: `m01-master__table-row${mark ? " is-changed" : ""}`, children: [ - item.key, - item.number, - item.sub, - item.detail, - item.name, - item.unit, - item.source, - state, - ].map((v) => el("td", { text: v, attrs: { title: v } })), + ...[item.key, item.number, item.sub, item.detail, item.name].map(cell), + aliasCell(item, mark === "deleted"), + ...[item.unit, item.source, state].map(cell), + ], }); if (item.blocked) tr.title = item.reasons.join("\n"); tr.addEventListener("click", () => onOpen(item)); return tr; }; + /** 별칭 칸 — 눌러도 로직이 안 열림 · 고친 글은 「a, b」 꼴로 */ + const aliasCell = (item: ListItem, locked: boolean): HTMLElement => { + const input = el("input", { + className: "m01-logic__alias", + attrs: { type: "text", value: aliasOf(item), placeholder: "—", title: tx("List_Alias") }, + }) as HTMLInputElement; + input.disabled = locked; + input.addEventListener("click", (e) => e.stopPropagation()); + input.addEventListener("change", () => { + const next = aliasText(input.value); + input.value = next; + if (next !== aliasOf(item)) onAlias(item, next); + }); + return el("td", { children: [input] }); + }; + search.addEventListener("input", () => go(1)); let pager = buildPager(0, SIZE, 1, go); @@ -128,6 +166,7 @@ export function buildList(onOpen: (item: ListItem) => void): ListHandle { key: x.키, number: x.원문번호, name: x.이름, + alias: x.별칭 ?? "", unit: x.결과단위, source: x.출처, blocked: x.blocked, @@ -135,9 +174,10 @@ export function buildList(onOpen: (item: ListItem) => void): ListHandle { })); draw(); }, - setMarks: (next, more) => { + setMarks: (next, more, edited) => { marks = next; extra = more; + aliases = edited; draw(); }, setPick: (sub, detail) => { diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Pick.ts b/M01_MasterData/M01_MasterData_UI_Logic_Pick.ts index 9a7886955..cdeee432b 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Pick.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Pick.ts @@ -2,6 +2,7 @@ * M01_MasterData_UI_Logic_Pick.ts * 고르기 두 가지 * ① 요소 찾기 창 — 그룹 전체에서 이름·키·원문번호로 찾아 호표 줄에 넣음(`GET /elements`) + * 로직 그룹은 별칭으로도 찾음(서버) · 별칭으로 맞은 줄은 그 별칭을 작게(공용 `matchLogic` · 208-1) * 표 고르기(소요량·계수)는 값칸 단추를 눌러 `찾기(…).칸` 으로 수량에 넣음 * ② 재료·기계 바꿔 고르기 — 구분 → 상세구분 → 후보 순으로 좁힘 * 바꾼 것은 시험 계산용 복사본에만 · 정본 로직은 그대로 @@ -14,6 +15,7 @@ import { el, showToast, } from "@ui/ui_template_elements"; +import { aliasNote, matchLogic } from "@ui/ui_template_logic_find"; import { fetchMaterials, searchElements, @@ -77,7 +79,11 @@ export function openPicker(mode: PickMode, group: string, done: PickDone): void const found = await searchElements(select.value, search.value.trim()); if (mine !== seq) return; // 최신 응답만 count.textContent = tx("Pick_Total", { n: found.total }); - list.replaceChildren(...found.items.map((item) => itemRow(item, mode, choose))); + const note = (item: ElementBrief): string => + select.value === "로직" + ? aliasNote(matchLogic(search.value, { ...item, 키: item.ref, 이름: item.이름 }).alias) + : ""; + list.replaceChildren(...found.items.map((item) => itemRow(item, mode, choose, note(item)))); } catch (error) { showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error"); } @@ -97,12 +103,16 @@ function itemRow( item: ElementBrief, mode: PickMode, choose: (item: ElementBrief, column?: string) => void, + note = "", ): HTMLElement { const text = el("div", { className: "m01-logic__stack", children: [ el("strong", { text: `${item.이름 ?? ""} ${item.규격 ?? ""}`.trim() }), - el("span", { className: "m01-logic__muted", text: item.ref }), + el("span", { + className: "m01-logic__muted", + text: [item.ref, note].filter(Boolean).join(" · "), + }), ], }); if (mode === "table") { diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Style.css b/M01_MasterData/M01_MasterData_UI_Logic_Style.css index 246bb49b9..db43968d9 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Style.css +++ b/M01_MasterData/M01_MasterData_UI_Logic_Style.css @@ -266,6 +266,23 @@ input.m01-logic__input { font-size: var(--text-caption); } +/* 목록 「별칭」 칸(208-1) — 칸 안 글 상자 · 테두리는 올렸을 때만 */ +.m01-logic__alias { + width: 160px; + padding: 2px var(--spacing-4); + border: 1px solid transparent; + border-radius: var(--radius-buttons); + background: transparent; + color: inherit; + font: inherit; +} + +.m01-logic__alias:hover, +.m01-logic__alias:focus { + border-color: var(--color-border); + background: var(--color-canvas); +} + .m01-logic__bad { color: var(--color-danger); font-size: var(--text-caption); diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Text.ts b/M01_MasterData/M01_MasterData_UI_Logic_Text.ts index c9090312b..6a7f7010a 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Text.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Text.ts @@ -7,7 +7,8 @@ import { currentLanguageIndex } from "@ui/ui_template_locale"; const TEXT = { Title: ["단가산출 로직", "Unit-price logic"], - List_Search: ["이름 찾기", "Search name"], + List_Search: ["이름 · 별칭 찾기", "Search name or alias"], + List_Alias: ["별칭 — 쉼표로 여럿 · 고친 뒤 [저장]", "Aliases — comma separated · then [Save]"], List_AllBooks: ["원문 모두", "All sources"], List_AllChapters: ["장 모두", "All chapters"], List_BlockedOnly: ["막힘만", "Blocked only"], diff --git a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts index a34d3c543..ebc94a459 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts @@ -17,6 +17,7 @@ import type { CfScope, CfVariable, } from "./M02_DF_Model"; +import { matchLogic } from "@ui/ui_template_logic_find"; /** 입력 상자가 고르는 그룹 — 화면 차례 */ export const GROUPS: CfGroup[] = [ @@ -285,21 +286,21 @@ export interface MasterLogicBrief { 원문번호: string; 이름: string; 결과단위: string; + /** 쉼표로 여럿(208-1) */ + 별칭?: string; blocked?: boolean; reasons?: string[]; } -/** 찾기 — 띄어쓰기로 나눈 말을 모두 포함(이름 · 원문번호 · 키 · 구분 · 상세구분 · 띄어쓰기는 가리지 않음) · 원문(구분) · 장(상세구분) 거름 */ +/** 찾기 — 공용 `matchLogic`(띄어 나눈 말마다 이름 · 원문번호 · 키 · 구분 · 상세구분 · 별칭 · 208-1) · 원문(구분) · 장(상세구분) 거름 */ export function filterLogics( items: MasterLogicBrief[], q: { text: string; book?: string; chapter?: string }, ): MasterLogicBrief[] { - const words = q.text.split(/\s+/).map(squash).filter(Boolean); return items.filter((i) => { if (q.book && i.구분 !== q.book) return false; if (q.chapter && i.상세구분 !== q.chapter) return false; - const hay = squash(`${i.이름} ${i.원문번호} ${i.키} ${i.구분} ${i.상세구분}`); - return words.every((w) => hay.includes(w)); + return matchLogic(q.text, i).ok; }); } diff --git a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Logic.ts b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Logic.ts index 04e9a4c80..af44df841 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Logic.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Logic.ts @@ -14,6 +14,7 @@ import { el, showToast, } from "@ui/ui_template_elements"; +import { aliasNote, matchLogic } from "@ui/ui_template_logic_find"; import { fetchAuto, fetchLogic, @@ -193,6 +194,11 @@ export function renderLogicInput( children: [ el("strong", { text: i.이름 }), el("span", { className: "m02-dfip__muted", text: ` ${i.원문번호}` }), + // 별칭으로 맞았으면 그 별칭을 작게(208-1) + el("span", { + className: "m02-dfip__muted", + text: ` ${aliasNote(matchLogic(q.input.value, i).alias)}`, + }), el("span", { className: "m02-dfip__tag", text: i.blocked ? t("Blocked") : i.상세구분, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts index 7dafc26ec..9d036b8c7 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts @@ -7,6 +7,7 @@ import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements"; import { createFindList } from "@ui/ui_template_find_list"; +import { aliasNote, matchLogic } from "@ui/ui_template_logic_find"; import { t as L } from "@ui/ui_template_locale"; import type { LogicSummary, SubBrief } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; @@ -83,7 +84,7 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra (x) => (!sub.select.value || x.구분 === sub.select.value) && (!detail.select.value || x.상세구분 === detail.select.value) && - (!q || `${x.키} ${x.이름}`.toLowerCase().includes(q)), + matchLogic(finder.search.value, x).ok, // 이름 · 키 · 별칭 …(공용 · 208-1) ); const libFound = libRows.filter( (x) => @@ -103,7 +104,9 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra key: x.키, tag: tc("Kind_Logic"), name: x.이름, - sub: `${x.키} · ${x.구분} ${x.상세구분}`.trim(), + sub: [`${x.키} · ${x.구분} ${x.상세구분}`.trim(), aliasNote(matchLogic(q, x).alias)] + .filter(Boolean) + .join(" · "), unit: `${x.결과단위}${x.blocked ? ` · ${tc("Pick_Blocked")}` : ""}`, onPick: () => { close(); diff --git a/resources/tester/test_208_1_logic_alias.py b/resources/tester/test_208_1_logic_alias.py new file mode 100644 index 000000000..bf5ac9e66 --- /dev/null +++ b/resources/tester/test_208_1_logic_alias.py @@ -0,0 +1,185 @@ +# -*- coding: utf-8 -*- +"""PLAN 208-1 — 단가산출 로직 「별칭」 칸 · 로직 찾기는 이름 + 별칭. + +① 서버 찾기 맞춤(`M01_MasterData_Store_Find.hit`) ↔ 화면 공용 맞춤(`ui_template_logic_find.ts`) 거울 +② 읽기(없으면 빈칸) · 저장 왕복 — 임시 사본 폴더만(정본 마스터 파일은 안 바뀜) +③ M01 목록 「별칭」 열(이름 옆) · 쓰는 곳마다 공용 맞춤을 부름 +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from M01_MasterData import M01_MasterData_Router as router_module +from M01_MasterData import M01_MasterData_Store as store +from M01_MasterData.M01_MasterData_Store_Find import hit + +ROOT = Path(__file__).resolve().parents[2] +REAL = store.FOLDER +LOGIC_KEY = "GC000091" # 공통 3-3-1 암발파(미진동굴착 TYPE-Ⅰ) +LOGIC_FILE = "로직_건설품셈_03장_토공사.json" + +ROW = { + "키": "GF000001", + "원문번호": "9-1-1", + "이름": "흙깎기(보통토사)", + "별칭": "절토, 터파기 기계", +} +# (찾는 글, 맞음, 별칭으로 맞은 것) +CASES = [ + ("흙깎기", True, ""), + ("흙 깎기", True, ""), # 띄어쓰기 무시 · 낱말마다 + ("절토", True, "절토"), + ("터파기", True, "터파기 기계"), + ("터파기기계", True, "터파기 기계"), # 별칭 안 띄어쓰기 무시 + ("절토 기계", True, "절토, 터파기 기계"), # 낱말이 여러 별칭에 나뉘어 듦 + ("흙깎기 절토", True, "절토"), # 이름 + 별칭 + ("gf000001", True, ""), + ("9-1", True, ""), + ("성토", False, ""), + ("", True, ""), +] + + +def _node(src: str) -> str: + if shutil.which("node") is None: + pytest.skip("node 가 없음") + done = subprocess.run( + ["node", "--experimental-strip-types", "--input-type=module", "-e", src], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + cwd=ROOT, + ) + assert done.returncode == 0, done.stderr + return done.stdout + + +def test_서버_맞춤_이름_별칭_여러_별칭() -> None: + for q, ok, _ in CASES: + assert hit(ROW, q) is ok, q + assert hit({**ROW, "별칭": ""}, "절토") is False # 별칭 없음 = 빈칸 + assert hit({k: v for k, v in ROW.items() if k != "별칭"}, "흙깎기") is True + + +def test_화면_맞춤_거울_서버와_같음() -> None: + url = (ROOT / "ui_template" / "ui_template_logic_find.ts").as_uri() + src = f""" +import {{ matchLogic, aliasText, aliasesOf, aliasNote }} from "{url}"; +const row = {json.dumps(ROW, ensure_ascii=False)}; +const cases = {json.dumps([c[0] for c in CASES], ensure_ascii=False)}; +console.log(JSON.stringify({{ + hits: cases.map((q) => matchLogic(q, row)), + text: aliasText(" 절토 ,, 터파기 기계,"), + none: aliasesOf(undefined), + note: [aliasNote("절토"), aliasNote("")], + chapter: matchLogic("토공", {{ ...row, 상세구분: "09장 토공" }}).ok, +}})); +""" + got = json.loads(_node(src)) + for (q, ok, alias), one in zip(CASES, got["hits"]): + assert one == {"ok": ok, "alias": alias}, q + assert one["ok"] is hit(ROW, q), q # 서버와 같은 판정 + assert got["text"] == "절토, 터파기 기계" + assert got["none"] == [] + assert got["note"] == ["별칭 절토", ""] + assert got["chapter"] is True # 구분 · 상세구분은 화면만 더 봄 + + +@pytest.fixture +def client(tmp_path: Path, monkeypatch) -> TestClient: + for path in REAL.glob("*.json"): + if not path.name.startswith("_") or path.name == store.mk.BOOK.name: + shutil.copy(path, tmp_path / path.name) + monkeypatch.setattr(store, "FOLDER", tmp_path) + app = FastAPI() + app.include_router(router_module.router) + return TestClient(app) + + +def _logics(client: TestClient, **params) -> list[dict]: + res = client.get("/api/m01/logics", params=params) + assert res.status_code == 200, res.text + return res.json()["logics"] + + +def _save(client: TestClient, row: dict) -> None: + one = client.get("/api/m01/logic", params={"key": LOGIC_KEY}).json() + part = { + "file": one["file"], + "version": one["version"], + "changes": [{"op": "edit", "key": LOGIC_KEY, "row": row}], + } + res = client.post("/api/m01/save", json={"files": [part]}) + assert res.status_code == 200, res.text + + +def test_읽기_빈칸_저장_왕복_임시_사본만(client: TestClient) -> None: + real = (REAL / LOGIC_FILE).read_bytes() + listed = _logics(client) + assert listed and all(isinstance(x["별칭"], str) for x in listed) # 없으면 빈칸 + one = client.get("/api/m01/logic", params={"key": LOGIC_KEY}).json() + base = one["logic"] + assert "별칭" not in base # 마스터 파일에 일괄로 쓰지 않음 + + _save(client, {**base, "별칭": "미진동 발파, 암깨기"}) + got = [x for x in _logics(client, q="암깨기") if x["키"] == LOGIC_KEY] + assert got and got[0]["별칭"] == "미진동 발파, 암깨기" + assert LOGIC_KEY in [x["키"] for x in _logics(client, q="미진동 발파")] + found = client.get("/api/m01/elements", params={"group": "로직", "q": "암깨기"}).json() + assert [x["별칭"] for x in found["items"] if x["ref"] == LOGIC_KEY] == ["미진동 발파, 암깨기"] + saved = json.loads((store.FOLDER / LOGIC_FILE).read_text(encoding="utf-8")) + assert next(r for r in saved["줄"] if r["키"] == LOGIC_KEY)["별칭"] == "미진동 발파, 암깨기" + + _save(client, base) # 지우면 칸째 빠짐 · 찾기에도 안 걸림 + assert LOGIC_KEY not in [x["키"] for x in _logics(client, q="암깨기")] + after = json.loads((store.FOLDER / LOGIC_FILE).read_text(encoding="utf-8")) + assert after == json.loads(real.decode("utf-8")) # 왕복 뒤 내용 그대로 + assert (REAL / LOGIC_FILE).read_bytes() == real # 정본은 안 건드림 + + +def _src(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + +def test_M01_목록_별칭_열은_이름_옆_고치면_캐시() -> None: + ts = _src("M01_MasterData/M01_MasterData_UI_Logic_List.ts") + cols = ts[ts.index("const COLS") : ts.index("as const")] + names = [x.strip().strip('"') for x in cols[cols.index("[") + 1 :].split(",")] + assert names[names.index("이름") + 1] == "별칭" # 이름 바로 옆 + assert "aliasCell(item" in ts and "onAlias(item, next)" in ts + assert "e.stopPropagation()" in ts # 별칭 칸을 눌러도 로직이 안 열림 + lab = _src("M01_MasterData/M01_MasterData_UI_LogicLab.ts") + assert "const onAlias = async" in lab and "list.setMarks(marks, extra, aliases)" in lab + # 자동저장 없음 — 캐시(drafts)에만 · [저장] 이 보냄 + body = lab[lab.index("const onAlias = async") : lab.index("const onDelete = async")] + assert "saveFiles" not in body and "persist()" in body + + +@pytest.mark.parametrize( + "rel", + [ + "M01_MasterData/M01_MasterData_UI_Logic_List.ts", # M01 로직 목록 + "M01_MasterData/M01_MasterData_UI_Logic_Pick.ts", # M01 요소 찾기 창 · 로직 그룹 + "M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts", # M02 흐름도 다른 로직 고르기 + "M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Logic.ts", # 〃 결과 별칭 표시 + "M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts", # M02 · B09 [로직 더하기] + ], +) +def test_쓰는_곳마다_공용_맞춤을_부름(rel: str) -> None: + ts = _src(rel) + assert 'from "@ui/ui_template_logic_find"' in ts + assert "matchLogic(" in ts + + +def test_서버_찾기도_한_벌() -> None: + py = _src("M01_MasterData/M01_MasterData_Store.py") + assert "import hit as _hit" in py and "def _hit" not in py diff --git a/resources/tester/test_m02_df_input_logic.py b/resources/tester/test_m02_df_input_logic.py index bbe4d9bb9..e0c659a65 100644 --- a/resources/tester/test_m02_df_input_logic.py +++ b/resources/tester/test_m02_df_input_logic.py @@ -18,12 +18,17 @@ const fs = require("fs"); const path = require("path"); const [root, file] = process.argv.slice(1); const ts = require(path.join(root, "config", "node_modules", "typescript")); -const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { - compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, -}).outputText; -const box = { exports: {} }; -new Function("exports", "module", "require", js)(box.exports, box, () => ({})); -const m = box.exports; +const load = (src, req) => { + const js = ts.transpileModule(fs.readFileSync(src, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + new Function("exports", "module", "require", js)(box.exports, box, req); + return box.exports; +}; +// 로직 찾기는 공용 맞춤(208-1) — 그것만 실제로 싣고 나머지 import 는 빈 것 +const find = load(path.join(root, "ui_template", "ui_template_logic_find.ts"), () => ({})); +const m = load(file, (p) => (p.endsWith("ui_template_logic_find") ? find : {})); (async () => { const jobs = JSON.parse(fs.readFileSync(0, "utf8")); const out = []; diff --git a/ui_template/ui_template_logic_find.ts b/ui_template/ui_template_logic_find.ts new file mode 100644 index 000000000..f67516f63 --- /dev/null +++ b/ui_template/ui_template_logic_find.ts @@ -0,0 +1,53 @@ +/* ============================================================================= + * ui_template_logic_find.ts + * 단가산출 로직 찾기 맞춤(208-1) — 로직을 찾는 화면은 모두 이것 하나를 부름. + * 낱말(띄어 나눔)마다 키 · 원문번호 · 이름 · 구분 · 상세구분 · 별칭(쉼표로 여럿) 가운데 하나에 들면 맞음 · + * 띄어쓰기 · 대소문자 무시. 별칭으로 맞았으면 그 별칭을 돌려줌(결과에 작게). + * 짝: `M01_MasterData_Store_Find.hit`(서버 찾기 — 키 · 원문번호 · 이름 · 별칭 · 낱말 규칙이 같음 · + * 구분 · 상세구분은 화면만) · 거울 시험 `test_208_1_logic_alias.py`. + * ========================================================================== */ + +export interface LogicFindRow { + 키?: string; + 원문번호?: string; + 이름?: string; + 구분?: string; + 상세구분?: string; + /** 쉼표로 여럿 · 없으면 빈칸 */ + 별칭?: string | null; +} + +export interface LogicFindHit { + ok: boolean; + /** 별칭으로 맞았으면 맞은 별칭(쉼표로 이음) · 아니면 빈 글 */ + alias: string; +} + +const squash = (v: unknown): string => + String(v ?? "") + .replace(/\s+/g, "") + .toLowerCase(); + +/** 별칭 칸 → 낱낱 별칭(쉼표로 나눔 · 빈 것 뺌) */ +export const aliasesOf = (v: unknown): string[] => + String(v ?? "") + .split(",") + .map((x) => x.trim()) + .filter(Boolean); + +/** 고친 별칭 글을 저장 꼴로 — 「a ,b,, c」 → 「a, b, c」 */ +export const aliasText = (v: unknown): string => aliasesOf(v).join(", "); + +/** 결과에 작게 붙이는 글 — 별칭으로 안 맞았으면 빈 글 */ +export const aliasNote = (alias: string): string => (alias ? `별칭 ${alias}` : ""); + +export function matchLogic(q: string, row: LogicFindRow): LogicFindHit { + const words = q.split(/\s+/).map(squash).filter(Boolean); + const fields = [row.키, row.원문번호, row.이름, row.구분, row.상세구분].map(squash); + const missed = words.filter((w) => !fields.some((f) => f.includes(w))); + if (!missed.length) return { ok: true, alias: "" }; + const aliases = aliasesOf(row.별칭); + const has = (a: string, w: string): boolean => squash(a).includes(w); + if (!missed.every((w) => aliases.some((a) => has(a, w)))) return { ok: false, alias: "" }; + return { ok: true, alias: aliases.filter((a) => missed.some((w) => has(a, w))).join(", ") }; +}