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 a7cc104cf..310825c63 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"; @@ -86,7 +87,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) => @@ -106,7 +107,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(", ") }; +}