diff --git a/M02_MasterTemplete/M02_Hopyo_Match.py b/M02_MasterTemplete/M02_Hopyo_Match.py index 3d66494f5..dc894c8a4 100644 --- a/M02_MasterTemplete/M02_Hopyo_Match.py +++ b/M02_MasterTemplete/M02_Hopyo_Match.py @@ -2,7 +2,8 @@ 차례 = ① 층 기억(같은 층 → 위 층 · 확정분) ② 이름 같음(정규화) · 씨앗(일위대가조합.json 메모 · 읽기만) -③ 정리 이름 같음(괄호 · 기계 낱말) ④ 후보 5(글자 두 개 묶음 점수 + 단위 맞음 + 산림품셈 덤) — +③ 정리 이름 같음(괄호 · 기계 낱말) ④ 후보 5(글자 두 개 묶음 점수 + 단위 맞음 + 산림품셈 덤 · +뜻 낱말이 하나도 안 겹치면 뺌 — 「잔토처리」 ↔ 「소금처리」) — ③ ④ 는 사용자 확정(층 기억) 뒤에만 들어감. 줄 갈래: 단가산출 · 기계 · 기타 = 로직 / 자재 · 별도계상 = 자재(M01 자재품목) / 호표 = 그 호표 / % = 안 들어감(로직 덧줄이 셈). @@ -80,6 +81,27 @@ def tidy(name: Any) -> str: return mm.plain(_PAREN.sub("", text)) +def words(name: Any) -> frozenset[str]: + """뜻 낱말 — 괄호 밖 한글 덩어리 · 영문 두 글자 넘는 것 + (낱글자 덩어리는 붙임 「면 목」 = 면목).""" + text = unicodedata.normalize("NFKC", str(name or "")).lower() + for old, new in MACH_WORDS: + text = text.replace(old, new) + out, single = [], "" + for piece in re.findall(r"[가-힣]+|[a-z]{2,}", _PAREN.sub(" ", text)): + if len(piece) == 1: + single += piece + continue + out += [single, piece] + single = "" + return frozenset(w for w in [*out, single] if len(w) > 1) + + +def near(mine: frozenset[str], theirs: frozenset[str]) -> bool: + """뜻 낱말이 하나라도 겹침(한쪽이 다른 쪽에 듦) — 내 낱말이 없으면 거르지 않음.""" + return not mine or any(a in b or b in a for a in mine for b in theirs) + + def _spec_text(text: Any) -> str: out = str(text or "") for old, new in MACH_WORDS: @@ -221,13 +243,15 @@ def _targets(whole_id: int) -> dict[str, Any]: "단위": str(row.get("단위") or ""), "plain": mm.plain(name), "spec": mm.plain_spec(row.get("규격")), - "tidy": tidy(re.sub(r"-(사급|관급)$", "", name.strip())), + "base": (base := re.sub(r"-(사급|관급)$", "", name.strip())), + "tidy": tidy(base), } ) names: dict[str, dict[str, list]] = {"로직": {}, "자재": {}} for pool, group in (("로직", logics), ("자재", mats)): for one in group: one["grams"] = _grams(one["tidy"]) + one["words"] = words(one["base"] if "base" in one else one["이름"]) names[pool].setdefault(one["plain"], []).append(one) return {"로직": logics, "자재": mats, "이름": names, "씨앗": _seeds()} @@ -278,8 +302,9 @@ def candidates( grams, unit = _grams(me), line.get("단위") text = mm.plain_spec(f"{line.get('이름')}{line.get('규격')}") scored, seen = [], set() + mine = words(line.get("이름")) for one in (got or targets())[pool]: - if not grams & one["grams"]: + if not grams & one["grams"] or not near(mine, one["words"]): continue score = 2 * len(grams & one["grams"]) / (len(grams) + len(one["grams"])) score += 1 if one["tidy"] == me else 0 diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css b/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css index 236b453c9..7a00414cc 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css @@ -49,6 +49,33 @@ font-variant-numeric: tabular-nums; } +/* 줄 표 — 갈래 · 수량 · 단위 · 엑셀 단가 · 맞춤 · 입력값 · 환산 · 단추 = 줄바꿈 없음(글자가 세로로 안 + 갈라짐 · 폭 = 글 폭) · 품명 · 규격 · 대상이 남는 폭 · 칸 여백 좁힘(공용 표보다 앞서게 두 갈래 이름) */ +.m02-hopyo__table.m02-hopyo__lines :is(th, td) { + padding: var(--spacing-8); +} + +.m02-hopyo__table.m02-hopyo__lines + :is(th, td):is( + :nth-child(1), + :nth-child(4), + :nth-child(5), + :nth-child(6), + :nth-child(7), + :nth-child(9), + :nth-child(10), + :nth-child(11) + ) { + white-space: nowrap; +} + +/* 입력값 — 길면 줄임표(전체 글은 칸 풍선 도움말) */ +.m02-hopyo__table.m02-hopyo__lines td:nth-child(9) { + max-width: 24em; + overflow: hidden; + text-overflow: ellipsis; +} + .m02-hopyo__line.is-out { color: var(--color-text-muted); } @@ -109,6 +136,12 @@ background: var(--color-surface-raised); } +/* 고르기 창 머리 — 제목이 두 줄이어도 [닫기] 글이 안 갈라지고 안 줄어듦 */ +.m02-hopyo__choose .ui-modal__top > .ui-btn { + flex-shrink: 0; + white-space: nowrap; +} + .m02-hopyo__cands { display: flex; flex-direction: column; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts index 4318fa45a..5601885cf 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts @@ -93,6 +93,10 @@ export const inputsText = (values: Record, unread: string[]): s .filter(Boolean) .join(" · "); +/** 만들기 결과 낱말 — [새로 만들기] = 「새로 만들어 덮음」 · 있던 것 = 「다시 씀」 · 그 밖 「만듦」 */ +export const builtWord = (got: Pick, fresh: boolean): string => + fresh ? "새로 만들어 덮음" : got.다시씀 ? "다시 씀" : "만듦"; + /** 확정 한 줄 몸 — 대상 · 입력값 · 환산(0 넘는 수) · 없으면 null */ export function memoryOf( target: HopyoTarget | null, @@ -200,7 +204,7 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement }); /* ── 호표 하나 — 줄 맞춤 ────────────────────────────────────── */ - async function drawOne(no: number, built?: HopyoBuilt): Promise { + async function drawOne(no: number, built?: HopyoBuilt & { 새로: boolean }): Promise { const got = await load( () => readHopyoOne(opt.scope, key, no), () => void drawOne(no), @@ -381,7 +385,7 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement }, }); body.append( - el("p", { className: "m02-hopyo__note", text: list.length ? "후보" : "후보 없음" }), + el("p", { className: "m02-hopyo__note", text: list.length ? "후보" : "가까운 것 없음" }), el("div", { className: "m02-hopyo__cands", children: list }), more, ); @@ -411,11 +415,10 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement try { const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: key, 번: no }; const got = await buildHopyo(opt.scope, from, fresh); - showToast( - got.빠진줄.length ? `만듦 — 빠진 줄 ${got.빠진줄.length}` : "만듦", - got.빠진줄.length ? "warning" : "success", - ); - await drawOne(no, got); + const word = builtWord(got, fresh); + const miss = got.빠진줄.length; + showToast(miss ? `${word} — 빠진 줄 ${miss}` : word, miss ? "warning" : "success"); + await drawOne(no, { ...got, 새로: fresh }); } catch (error) { fail(error); } @@ -444,11 +447,15 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement ], }); - function builtBlock(no: number, got: HopyoBuilt): HTMLElement { + function builtBlock(no: number, got: HopyoBuilt & { 새로: boolean }): HTMLElement { const c = got.비교; const again = got.다시씀 ? [ - note(`있던 사전조합 ${got.참조.키} 를 다시 씀`), + note( + got.새로 + ? `사전조합 ${got.참조.키} 를 새로 만들어 덮음` + : `있던 사전조합 ${got.참조.키} 를 다시 씀`, + ), createButton({ label: "새로 만들기", variant: "danger", diff --git a/resources/tester/helper_m02_hopyo_pick.cjs b/resources/tester/helper_m02_hopyo_pick.cjs index 0ab06e6e8..a061c282c 100644 --- a/resources/tester/helper_m02_hopyo_pick.cjs +++ b/resources/tester/helper_m02_hopyo_pick.cjs @@ -74,6 +74,11 @@ function loadTs(file) { view.memoryOf({ 로직: "GF000101" }, {}, 0), view.memoryOf(null, {}, 1), ], + word: [ + view.builtWord({ 다시씀: false }, false), + view.builtWord({ 다시씀: true }, false), + view.builtWord({ 다시씀: true }, true), + ], }, }; process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_m02_hopyo_match.py b/resources/tester/test_m02_hopyo_match.py index 38f3039b7..0f1224240 100644 --- a/resources/tester/test_m02_hopyo_match.py +++ b/resources/tester/test_m02_hopyo_match.py @@ -185,3 +185,18 @@ def test_로직_추가_고르기_줄은_이_층과_위_층(client): assert three["맞춤"][1] == 4 system = client.get("/api/m02/layers/system/hopyo-picks").json()["줄"] assert {r["층"] for r in system} == {"system"} # 마스터 = 마스터만 + + +def test_뜻_낱말이_안_겹치면_후보_아님(): + assert hm.words("면 목") == {"면목"} and hm.words("구조물되메우기(인력100%)") == { + "구조물되메우기" + } + assert hm.words("되메우기 및 다짐(소형장비)") == {"되메우기", "다짐"} + soil = {"이름": "잔토처리(인력100%)", "규격": "", "단위": "㎥", "갈래": "단가산출"} + assert hm.candidates(soil, "로직") == [] # 「처리」 두 글자만 겹침(소금처리 · 바탕처리) + stone = {"이름": "임도표지석(받침,글새김포함)", "규격": "", "단위": "㎥", "갈래": "자재"} + assert hm.candidates(stone, "자재") == [] # 속도표지판 · 도로표지용페인트 + got = hm.match_line(soil, [], {}) + assert got["상태"] == "없음" and got["후보"] == [] + fill = {"이름": "구조물되메우기(인력100%)", "규격": "", "단위": "㎥", "갈래": "단가산출"} + assert hm.candidates(fill, "로직")[0]["이름"] == "되메우기" # 한쪽 낱말에 듦 = 겹침 diff --git a/resources/tester/test_m02_hopyo_screen.py b/resources/tester/test_m02_hopyo_screen.py index 92fb610d9..46345114b 100644 --- a/resources/tester/test_m02_hopyo_screen.py +++ b/resources/tester/test_m02_hopyo_screen.py @@ -99,6 +99,21 @@ def test_본_화면_글_도우미(): None, # 환산 0 = 확정 못 함 None, # 대상 없음 ] + assert view["word"] == ["만듦", "다시 씀", "새로 만들어 덮음"] # 알림 · 판 글 + + +def test_줄_표_줄바꿈_없음과_고르기_창_머리(): + css = _text("M02_MasterTemplete_Hopyo.css") + rule = css.split(".m02-hopyo__table.m02-hopyo__lines\n :is(th, td):is(")[1].split("}")[0] + cols = [int(n) for n in re.findall(r":nth-child\((\d+)\)", rule)] + assert cols == [1, 4, 5, 6, 7, 9, 10, 11] and "white-space: nowrap;" in rule + heads = ["갈래", "품명", "규격", "수량", "단위", "엑셀 단가", "맞춤", "대상", "입력값", "환산"] + view = _text("M02_MasterTemplete_Hopyo_View.ts") + flat = re.sub(r"\s+", "", view) + assert re.sub(r"\s+", "", ",".join(f'"{h}"' for h in heads)) in flat # 열 차례 그대로 + top = css.split(".m02-hopyo__choose .ui-modal__top > .ui-btn {")[1].split("}")[0] + assert "flex-shrink: 0;" in top and "white-space: nowrap;" in top + assert '"가까운 것 없음"' in view and "builtWord(got, fresh)" in view def test_좌측_판_자리와_권한():