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:
@@ -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)
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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(", ") };
|
||||
}
|
||||
Reference in New Issue
Block a user