Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1

# Conflicts:
#	docs/raw/요구_1010_2/사용자요구_1010_2.md
This commit is contained in:
2026-10-10 15:15:39 +09:00
14 changed files with 411 additions and 43 deletions
+5 -10
View File
@@ -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:
@@ -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)
+28 -2
View File
@@ -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<string, ListMark>();
const extra: ListItem[] = [];
const aliases = new Map<string, string>();
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<void> => {
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<void> => {
if (!opened?.row) return;
if (
@@ -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<string, string> | null;
/** 로직 그룹만 — 별칭(208-1) */
별칭?: string;
/** 재료 후보 줄 — 값 열 다섯(지역·계약종별별 값) */
값들?: Record<string, unknown>;
}
+56 -16
View File
@@ -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<string, ListMark>, extra: ListItem[]) => void;
/** 저장 안 한 새 로직도 목록 맨 위에 · aliases = 캐시에 고친 별칭(id → 글) */
setMarks: (marks: Map<string, ListMark>, extra: ListItem[], aliases: Map<string, string>) => 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<string, ListMark>();
let aliases = new Map<string, string>();
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) => {
+12 -2
View File
@@ -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") {
@@ -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);
@@ -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"],
@@ -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;
});
}
@@ -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.상세구분,
@@ -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();
+185
View File
@@ -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
+11 -6
View File
@@ -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 = [];
+53
View File
@@ -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(", ") };
}