From 8c562812da0dc078bd8a2a67d93fcbda5b25b4b5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 20:55:48 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(M02=20=EC=8B=A4=EB=AC=B4=EC=9E=90?= =?UTF-8?q?=EB=A3=8C):=20358-1=20=ED=9B=84=EB=B3=B4=20=EB=AC=B6=EC=9D=8C?= =?UTF-8?q?=20=E2=80=94=20=EC=9D=B4=EB=A6=84=ED=9B=84=EB=B3=B4(=EC=98=9B?= =?UTF-8?q?=20324=20=EB=8B=A4=EC=84=AF)=20=C2=B7=20=EB=9C=BB=EA=B2=B9?= =?UTF-8?q?=EC=B9=A8=20=C2=B7=20=EC=B6=9C=EC=B2=98=20=EA=B0=99=EC=9D=8C=20?= =?UTF-8?q?=C2=B7=20=EC=B1=85=20=ED=91=9C=EC=8B=9C=20=C2=B7=20=EA=B3=A0?= =?UTF-8?q?=EB=A5=B4=EA=B8=B0=20=EC=B0=BD=20=EB=AC=B6=EC=9D=8C=20=C2=B7=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EC=B9=B8=20=3D=20=ED=92=88=EB=AA=85=20?= =?UTF-8?q?=EB=82=B1=EB=A7=90=20=C2=B7=20=EC=9D=BC=EA=B4=84=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9(N)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버 줄 맞춤에 이름후보(옛 데스크탑 판과 같음 · 600줄 대조 0 다름) · 후보마다 뜻겹침 · 출처같음(조항 ≥ 0.9) · 책 — 차례 · 무게 · 상태 그대로 - 고르기 창 = 1위 / 출처 같음 / 이름 닮은 로직 / 숫자만 닮음(접힘 · 뜻 안 겹치는 후보 회색 끝) · 같은 이름 책 표시 - 버튼 명사형 — 일괄 적용(N) · 엑셀 보기 · 다른 로직 검색 · 재료비 있는 줄은 다른 자재 검색도 - 시험: match 이름후보 다섯 · 되메우기 · 다웰바 · golden 다섯 안 ∪ 이름후보 · screen 묶음 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte --- M02_MasterTemplete/M02_Hopyo_Line.py | 28 +++- M02_MasterTemplete/M02_Hopyo_Match.py | 24 ++- .../M02_MasterTemplete_Hopyo.css | 11 ++ .../M02_MasterTemplete_Hopyo_Api.ts | 6 + .../M02_MasterTemplete_Hopyo_Choose.ts | 143 ++++++++++++++---- resources/tester/helper_m02_hopyo_check.cjs | 36 +++++ resources/tester/test_m02_hopyo_golden.py | 15 ++ resources/tester/test_m02_hopyo_match.py | 23 +++ resources/tester/test_m02_hopyo_screen.py | 11 +- 9 files changed, 263 insertions(+), 34 deletions(-) diff --git a/M02_MasterTemplete/M02_Hopyo_Line.py b/M02_MasterTemplete/M02_Hopyo_Line.py index 60a842428..651edd4b3 100644 --- a/M02_MasterTemplete/M02_Hopyo_Line.py +++ b/M02_MasterTemplete/M02_Hopyo_Line.py @@ -11,6 +11,8 @@ 장부 짝이 규격 다름(품명|단위 느슨 열쇠)이면 ⑤ 아님 — 꼬리표 「별칭 · 규격 다름」 · 짝은 후보 끝 덤만. 자재 · 별도계상 = 이름 · 규격 같음 하나면 맞음(체크) · 그 밖 후보. +로직 줄 후보 묶음 표시(358-1) = 이름후보(옛 324 다섯 `Match.name_cands`) · 후보마다 뜻겹침 · 출처같음 · +책 — 차례 · 무게 · 상태는 그대로. 입력값 출처(칸마다) = 별칭 > 블록(옵션 찾기 값이 규격 읽기와 다름) > 규격(`spec_inputs`) > 자동값. 체크 = 처음 제안일 뿐 — 들어감은 [확인하고 사전조합 만들기](다 창)만. 저장 없음 · 셈은 서버 단독. 장부 = `M02_Hopyo_Ledger`(다 창) · 확인된 사전조합 = `M02_Hopyo_Build.confirmed`(다 창). @@ -214,15 +216,35 @@ def match_line(line: dict[str, Any], ctx: Ctx, full: bool = True) -> dict[str, A if alias and alias.get("갈래") in SKIP: return {**out, "상태": "뺌", "뺌": alias["갈래"], "별칭": _alias_info(alias, False)} if alias and alias.get("갈래") == "짝" and alias.get("짝"): - return {**out, **_aliased(line, ctx, alias, entry)} - if kind in hm.MATERIAL_KINDS: + got = _aliased(line, ctx, alias, entry) + elif kind in hm.MATERIAL_KINDS: got = _material(line, ctx, full) elif entry and entry["판정"]["글"] not in (fit.NO_NUMBER, "우리 로직 준비 중"): got = _numeric(line, ctx, entry) if full else _brief(entry) else: why = "셈 중" if not ctx.calc["끝"] else fit.NO_NUMBER got = _names_only(line, ctx, why, full) - return {**out, **(_loose(got, loose, line, ctx) if loose and full else got)} + got = _loose(got, loose, line, ctx) if loose and full else got + return {**out, **(_grouped(got, line, ctx) if full and kind not in hm.MATERIAL_KINDS else got)} + + +def _grouped(got: dict, line: dict, ctx: Ctx) -> dict: + """묶음 표시(358-1) — 후보 · 이름후보(옛 324 다섯)마다 뜻겹침 · 출처같음(조항 ≥ 0.9) · 책 · + 차례 · 무게 · 상태 그대로(아무것도 숨기지 않음 · 묶음은 화면 몫).""" + mine = hm.words(line.get("이름")) + theirs = hm.fix_typo(((line.get("근거") or {}).get("출처")) or []) + table = hopyo_basis.ours() + + def mark(c: dict) -> dict: + one, our = ctx.names.get(c.get("로직")), table.get(c.get("로직")) + if one is None: + return c + same = bool(theirs and our) and hm.clause(theirs, our)[0] >= 0.9 + book = (our or {}).get("책", "") + return {**c, "뜻겹침": hm.near(mine, one["words"]), "출처같음": same, "책": book} + + named = [mark(c) for c in hm.name_cands(line, ctx.got)] + return {**got, "후보": [mark(c) for c in got.get("후보") or []], "이름후보": named} def _loose(got: dict, alias: dict, line: dict, ctx: Ctx) -> dict: diff --git a/M02_MasterTemplete/M02_Hopyo_Match.py b/M02_MasterTemplete/M02_Hopyo_Match.py index 3bc65074d..5f77d3b4e 100644 --- a/M02_MasterTemplete/M02_Hopyo_Match.py +++ b/M02_MasterTemplete/M02_Hopyo_Match.py @@ -292,10 +292,14 @@ def _exact(line: dict[str, Any], pool: str, got: dict[str, Any]) -> list[dict[st def candidates( - line: dict[str, Any], pool: str, got: dict[str, Any] | None = None, limit: int | None = 5 + line: dict[str, Any], + pool: str, + got: dict[str, Any] | None = None, + limit: int | None = 5, + fold: bool = False, ) -> list[dict[str, Any]]: """③ 정리 이름 같음 먼저 · ④ 글자 두 개 묶음 점수 + 단위 맞음 + 산림품셈 덤 — 위 `limit` - (None = 모두).""" + (None = 모두) · `fold` = 로직도 (이름, 규격, 단위)로 접음(옛 324 꼴).""" me = tidy(line.get("이름")) grams, unit = _grams(me), line.get("단위") text = mm.plain_spec(f"{line.get('이름')}{line.get('규격')}") @@ -313,7 +317,8 @@ def candidates( scored.sort(key=lambda x: -x[0]) out = [] for score, one in scored: - label = one.get("로직") or (one["이름"], one.get("규격"), one["단위"]) # 로직은 접지 않음 + label = (one["이름"], one.get("규격"), one["단위"]) + label = label if fold else one.get("로직") or label # 로직은 접지 않음(fold 아니면) if label in seen: continue seen.add(label) @@ -323,6 +328,19 @@ def candidates( return out +def name_cands(line: dict[str, Any], got: dict[str, Any] | None = None) -> list[dict[str, Any]]: + """이름 닮은 로직 다섯(358-1 · 옛 324 데스크탑 판 그대로) — 이름 같음 · 씨앗(점수 9) 먼저 + + 이름 점수 후보(로직도 이름 · 규격 · 단위로 접음) · 숫자 판정 차례와 따로(표시 묶음만).""" + got = got or targets() + same = [_public(x) for x in _exact(line, "로직", got)] + rest = [ + c + for c in candidates(line, "로직", got, 5, fold=True) + if {k: v for k, v in c.items() if k != "점수"} not in same + ] + return ([{**x, "점수": 9} for x in same] + rest)[:5] + + # ── 인용 조항 · 이름 · 근거 차례(338 · 설계 2-4 B 차례) ───────────────────── _OLD_TEXT = re.compile(r"(20(1\d|2[0-4])|1\d년|2[0-4]년|임도품셈|임도표준품셈|사방품셈)") _TYPO_CLAUSE = re.compile(r"^(\d+)-(\d)(\d)(\d)$") # 13-114 → 13-11-4 diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css b/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css index 21202d055..02306d1ff 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css @@ -212,6 +212,17 @@ gap: var(--spacing-4); } +/* 고르기 창 후보 묶음(358-1) — 뜻 낱말 안 겹치는 「후보」 = 회색 · 숫자만 닮음 = 접힘 */ +.m02-hopyo__cand.is-weak { + color: var(--color-text-muted); +} + +.m02-hopyo__group > summary { + cursor: pointer; + color: var(--color-text-secondary); + margin-bottom: var(--spacing-4); +} + /* 별칭 장부(338) — 위 층 줄 = 회색 읽기만 · 고친 줄 = 왼쪽 띠 */ .m02-hopyo-ledger__row.is-upper { color: var(--color-text-muted); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts index 818aec359..2b1d01c28 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts @@ -92,6 +92,10 @@ export interface HopyoTarget { /** 후보 판정(338) — 맞음 · 다른 점 · 후보 */ 판정?: "맞음" | "다른 점" | "후보"; 글?: string; + /** 묶음 표시(358-1 · 로직 후보만) — 품명 뜻 낱말 겹침 · 엑셀 출처 조항 같음(≥ 0.9) · 책(산림품셈 · 건설품셈) */ + 뜻겹침?: boolean; + 출처같음?: boolean; + 책?: string; } /** 맞춤 대상 하나(338 · 로직 여럿 가능) — 배 = 환산 × 몫 · 우리 수량 = 엑셀 수량 × 배 */ @@ -124,6 +128,8 @@ export interface HopyoMatch { 체크: boolean; 대상: HopyoPart[]; 후보?: HopyoTarget[]; + /** 이름 닮은 로직 다섯(358-1 · 옛 324 판 그대로 · 숫자 판정 차례와 따로 · 로직 줄만) */ + 이름후보?: HopyoTarget[]; 글?: string; 꼬리표?: string[]; 품?: { 엑셀: HopyoQty; 우리: HopyoQty; 같음: Record }; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Choose.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Choose.ts index 26046f7fc..cf8022f09 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Choose.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Choose.ts @@ -1,10 +1,11 @@ /* ============================================================================= * M02_MasterTemplete_Hopyo_Choose.ts - * 일위대가 실무자료 고르기 창(PLAN 338-3 · 설계 4-2 · 옛 `Hopyo_View.choose` 를 옮김) + 글 도우미. - * · 위 = 엑셀 블록 품(직종 · 장비 · 자재) · 인용 조항 · [엑셀에서 보기](단가산출 그 행) - * · 후보 다섯 = 이름 · 키 · 판정 꼬리표(맞음 / 다른 점 / 후보) · 글 · 근거 꼬리표(근거 맞음 초록) · 금액 비 없음 - * · 고름 = [고름](한 로직) · 또는 체크 + 몫 칸 → [고른 n개 함께](조합) · 옵션은 부르는 쪽이 서버 `guess` 로 채움 - * · [다른 로직 찾기] = `openLogicPicker` 에 찾기 글 미리 넣음(엑셀 인용 조항 번호 → 없으면 품명 첫 뜻 낱말) + * 일위대가 실무자료 고르기 창(PLAN 338-3 · 358 · 설계 4-2 · 옛 `Hopyo_View.choose` 를 옮김) + 글 도우미. + * · 위 = 엑셀 블록 품(직종 · 장비 · 자재) · 인용 조항 · [엑셀 보기](단가산출 그 행) + * · 후보 묶음(358-1 · 숨김 없음) = 1위 + 판정 / 출처 같음 / 이름 닮은 로직(옛 324 다섯) / 숫자만 닮음(접힘 · + * 뜻 낱말 안 겹치는 「후보」는 회색 끝) · 같은 이름이면 책(산림 · 건설) · 판정 꼬리표 · 근거 꼬리표(근거 맞음 초록) + * · 고름 = [후보](한 로직) · 또는 체크 + 몫 칸 → [일괄 적용(n)](조합) · 옵션은 부르는 쪽이 서버 `guess` 로 채움 + * · [다른 로직 검색] = `openLogicPicker` 에 품명 첫 뜻 낱말 · [다른 자재 검색] = 자재 줄 · 재료비 있는 줄(358-2) * 저장 안 함 — 고른 것은 부르는 쪽 캐시(확인 창)로만 감. * ========================================================================== */ @@ -15,6 +16,7 @@ import type { HopyoBasisHit, HopyoLine, HopyoLineBasis, + HopyoMatch, HopyoQty, HopyoTarget, } from "./M02_MasterTemplete_Hopyo_Api"; @@ -95,13 +97,71 @@ export const qtyText = (q: HopyoQty | undefined): string => ].join(" · ") : ""; -/** [다른 로직 찾기] 미리 넣을 글 — 엑셀 인용 조항 번호 → 없으면 품명 괄호 밖 첫 뜻 낱말 */ -export function searchHint(line: Pick): string { - const clause = line.근거?.출처?.find((x) => x[1])?.[1]; - if (clause) return clause; +/** [다른 로직 검색] 미리 넣을 글 — 품명 괄호 밖 첫 뜻 낱말(358-1 · 조항 번호는 모든 낱말 맞춤이라 0건 위험) */ +export function searchHint(line: Pick): string { return /[가-힣A-Za-z]{2,}/.exec(line.이름.replace(/\([^()]*\)/g, " "))?.[0] ?? ""; } +/** 후보 묶음 하나 — 제목 · 접힘(숫자만 닮음) · 후보 */ +export interface CandGroup { + 제목: string; + 접힘: boolean; + 후보: HopyoTarget[]; +} + +/** 약한 후보 — 판정 「후보」 + 품명 뜻 낱말 안 겹침(회색 · 묶음 끝) */ +export const weakCand = (c: HopyoTarget): boolean => c.판정 === "후보" && c.뜻겹침 === false; + +/** 후보 묶음(358-1 · 숨김 없음 · 차례는 서버 그대로) — 1위 + 판정 / 출처 같음 / 이름 닮은 로직(옛 판 다섯) / + * 숫자만 닮음(접힘 · 약한 후보 뒤로) · 한 후보는 한 묶음에만 · 자재 줄 = 1위 / 그 밖 후보 */ +export function candGroups( + m: Pick, + material = false, +): CandGroup[] { + const all = m.후보 ?? []; + const seen = new Set(); + const take = (list: HopyoTarget[]): HopyoTarget[] => + list.filter((c) => { + const id = c.로직 ?? c.자재 ?? c.이름 ?? ""; + if (seen.has(id)) return false; + seen.add(id); + return true; + }); + const top = take(all.slice(0, 1)); + if (material) + return [ + { 제목: "1위", 접힘: false, 후보: top }, + { 제목: "그 밖 후보", 접힘: false, 후보: take(all.slice(1)) }, + ].filter((g) => g.후보.length); + const named = m.이름후보 ?? []; + const source = take([...all.slice(1), ...named].filter((c) => c.출처같음)); + const name = take(named); + const rest = take(all.slice(1)); + rest.sort((a, b) => Number(weakCand(a)) - Number(weakCand(b))); + return [ + { 제목: "1위", 접힘: false, 후보: top }, + { 제목: "출처 같음", 접힘: false, 후보: source }, + { 제목: "이름 닮은 로직", 접힘: false, 후보: name }, + { 제목: "숫자만 닮음", 접힘: true, 후보: rest }, + ].filter((g) => g.후보.length); +} + +/** 같은 이름 후보 — 책(산림 · 건설)을 붙여 가를 이름 */ +export function sameNames(groups: CandGroup[]): Set { + const count = new Map(); + for (const c of groups.flatMap((g) => g.후보)) + count.set(c.이름 ?? "", (count.get(c.이름 ?? "") ?? 0) + 1); + return new Set([...count].filter(([name, n]) => name && n > 1).map(([name]) => name)); +} + +/** 후보 단추 글 — 「이름 [단위] · 키」 · 같은 이름이면 책 두 글자(산림 · 건설) */ +export const candText = (c: HopyoTarget, twins: Set): string => + `${targetText(c)} · ${c.로직 ?? c.자재 ?? ""}${twins.has(c.이름 ?? "") && c.책 ? ` · ${c.책.slice(0, 2)}` : ""}`; + +/** 자재 고름 — 자재 · 별도계상 줄 · 재료비 있는 단가산출 줄(358-2 · P.V.C파이프설치 등) */ +export const canPickMaterial = (line: Pick): boolean => + MATERIAL.has(line.갈래) || !!line.단가?.재료비; + /** 고른 후보 → 만들기 대상(배 1 · 몫 있으면 배 = 몫) */ export const pickedTargets = (picked: { c: HopyoTarget; 몫: number | null }[]): BuildTarget[] => picked.map(({ c, 몫 }) => ({ @@ -113,12 +173,12 @@ export const pickedTargets = (picked: { c: HopyoTarget; 몫: number | null }[]): export interface ChooseOptions { line: HopyoLine; - /** [엑셀에서 보기] — 단가산출 그 행 */ + /** [엑셀 보기] — 단가산출 그 행 */ openSheet?: (행: [number, number]) => void; done: (targets: BuildTarget[]) => void; } -/** 고르기 창 — 엑셀 품 · 인용 조항 · 후보 다섯(하나 또는 여럿 · 몫) · 다른 로직 · 자재 찾기 */ +/** 고르기 창 — 엑셀 품 · 인용 조항 · 후보 묶음(하나 또는 여럿 · 몫) · 다른 로직 · 자재 검색 */ export function openHopyoChoose(opt: ChooseOptions): void { const { line } = opt; const material = MATERIAL.has(line.갈래); @@ -132,9 +192,9 @@ export function openHopyoChoose(opt: ChooseOptions): void { opt.done(targets); }; const picked = new Map(); - const together = createButton({ label: "고른 0개 함께", size: "sm", disabled: true }); + const together = createButton({ label: "일괄 적용(0)", size: "sm", disabled: true }); const paint = (): void => { - together.querySelector(".ui-btn__label")!.textContent = `고른 ${picked.size}개 함께`; + together.querySelector(".ui-btn__label")!.textContent = `일괄 적용(${picked.size})`; together.disabled = picked.size < 2; }; together.addEventListener("click", () => @@ -144,7 +204,9 @@ export function openHopyoChoose(opt: ChooseOptions): void { ), ), ); - const list = (line.맞춤.후보 ?? []).map((c) => { + const groups = candGroups(line.맞춤, material); + const twins = sameNames(groups); + const row = (c: HopyoTarget): HTMLElement => { const share = el("input", { className: "ui-input ui-input--sm m02-hopyo__times", attrs: { type: "number", step: "0.05", min: "0", max: "1", placeholder: "몫" }, @@ -157,12 +219,12 @@ export function openHopyoChoose(opt: ChooseOptions): void { }); const tag = basisTag(c.근거); return el("div", { - className: "m02-hopyo__cand", + className: `m02-hopyo__cand${weakCand(c) ? " is-weak" : ""}`, attrs: { "data-cand": c.로직 ?? c.자재 ?? "" }, children: [ box, createButton({ - label: `${targetText(c)} · ${c.로직 ?? c.자재 ?? ""}`, + label: candText(c, twins), variant: "ghost", size: "sm", onClick: () => finish(pickedTargets([{ c, 몫: null }])), @@ -176,6 +238,7 @@ export function openHopyoChoose(opt: ChooseOptions): void { ] : []), ...(c.글 ? [note(c.글)] : []), + ...(weakCand(c) ? [note("이름 뜻 안 겹침")] : []), ...(tag && c.근거 ? [ el("span", { @@ -188,6 +251,18 @@ export function openHopyoChoose(opt: ChooseOptions): void { share, ], }); + }; + const sections = groups.map((g) => { + const list = el("div", { className: "m02-hopyo__cands", children: g.후보.map(row) }); + const title = `${g.제목} (${g.후보.length})`; + if (!g.접힘) + return el("div", { attrs: { "data-group": g.제목 }, children: [note(title), list] }); + const fold = el("details", { + className: "m02-hopyo__group", + attrs: { "data-group": g.제목 }, + children: [el("summary", { text: `${title} — 이름 · 출처 안 닮음 · 숫자 지문만` }), list], + }); + return fold; }); const excel = qtyText(line.맞춤.품?.엑셀); const basis = line.근거; @@ -199,7 +274,7 @@ export function openHopyoChoose(opt: ChooseOptions): void { ...(basis && opt.openSheet ? [ createButton({ - label: "엑셀에서 보기", + label: "엑셀 보기", variant: "ghost", size: "sm", onClick: () => opt.openSheet?.(basis.행), @@ -208,23 +283,37 @@ export function openHopyoChoose(opt: ChooseOptions): void { : []), ], }); - const more = createButton({ - label: material ? "다른 자재 찾기…" : "다른 로직 찾기…", + const findMaterial = createButton({ + label: "다른 자재 검색…", size: "sm", onClick: () => { close(); - if (material) pickMaterial((x) => opt.done([{ 자재: x.코드, 배: 1 }])); - else - openLogicPicker((x) => opt.done([{ 로직: x.키, 입력값: {}, 배: 1 }]), undefined, { - query: searchHint(line), - }); + pickMaterial((x) => opt.done([{ 자재: x.코드, 배: 1 }])); }, }); + const findLogic = createButton({ + label: "다른 로직 검색…", + size: "sm", + onClick: () => { + close(); + openLogicPicker((x) => opt.done([{ 로직: x.키, 입력값: {}, 배: 1 }]), undefined, { + query: searchHint(line), + }); + }, + }); + const more = [ + ...(material ? [] : [findLogic]), + ...(canPickMaterial(line) ? [findMaterial] : []), + ]; body.append( ...(head.childElementCount ? [head] : []), - note(list.length ? "후보 — 하나 고름 · 여럿은 체크(몫 칸) 뒤 [함께]" : "가까운 것 없음"), - el("div", { className: "m02-hopyo__cands", children: list }), - el("div", { className: "m02-hopyo__bar", children: [together, more] }), + note( + groups.length + ? "후보 — 하나 고름 · 여럿은 체크(몫 칸) 뒤 [일괄 적용]" + : "가까운 것 없음 — 아래 검색", + ), + ...sections, + el("div", { className: "m02-hopyo__bar", children: [together, ...more] }), ); }, }); diff --git a/resources/tester/helper_m02_hopyo_check.cjs b/resources/tester/helper_m02_hopyo_check.cjs index dc55d7522..3bfc7ab22 100644 --- a/resources/tester/helper_m02_hopyo_check.cjs +++ b/resources/tester/helper_m02_hopyo_check.cjs @@ -159,6 +159,42 @@ let out0 = null; word: [choose.builtWord({ 다시씀: false }), choose.builtWord({ 다시씀: true })], qty: choose.qtyText(lines[1].맞춤.품.엑셀), target: choose.targetText({ 호표: 46, 단위: "㎡" }), + // 358-1 후보 묶음 — 1위 / 출처 같음 / 이름 닮은 로직 / 숫자만 닮음(약한 후보 끝) · 같은 이름 책 + groups: (() => { + const m = { + 후보: [ + { 로직: "A", 이름: "되메우기", 판정: "맞음", 뜻겹침: true, 책: "산림품셈" }, + { 로직: "B", 이름: "야면석 채집", 판정: "후보", 뜻겹침: false }, + { + 로직: "C", + 이름: "되메우기", + 판정: "후보", + 뜻겹침: true, + 출처같음: true, + 책: "건설품셈", + }, + { 로직: "D", 이름: "표토펴기", 판정: "다른 점", 뜻겹침: false }, + ], + 이름후보: [ + { 로직: "A", 이름: "되메우기" }, + { 로직: "E", 이름: "되메우기(노상재)" }, + ], + }; + const g = choose.candGroups(m); + const twins = choose.sameNames(g); + return { + groups: g.map((x) => [x.제목, x.접힘, x.후보.map((c) => c.로직)]), + text: g.flatMap((x) => x.후보).map((c) => choose.candText(c, twins)), + material: choose + .candGroups({ 후보: [{ 자재: "M1" }, { 자재: "M2" }] }, true) + .map((x) => x.제목), + pick: [ + choose.canPickMaterial({ 갈래: "단가산출", 단가: { 재료비: 10 } }), + choose.canPickMaterial({ 갈래: "단가산출", 단가: { 재료비: null } }), + choose.canPickMaterial({ 갈래: "별도계상", 단가: {} }), + ], + }; + })(), }, ledger: (() => { const rows = input.ledger; diff --git a/resources/tester/test_m02_hopyo_golden.py b/resources/tester/test_m02_hopyo_golden.py index 1006a4aa4..76a4a915c 100644 --- a/resources/tester/test_m02_hopyo_golden.py +++ b/resources/tester/test_m02_hopyo_golden.py @@ -80,3 +80,18 @@ def test_씨앗_끔_1순위(no_seeds): def test_원본_그대로_셈_무게(run): assert master_hash() == run["원본"] assert run["결과"]["시간"]["북부청임도"] <= 300 # 북부청 임도 맞춤 셈 ≤ 5 분 + + +def test_이름후보_묶음_덧(run): + """358-1 — 이름후보(옛 324 다섯) = 묶음 덧 · 숫자 차례 그대로 → 다섯 안 ∪ 이름후보 ≥ 다섯 안.""" + have = [ + r for r in run["결과"]["줄"].values() if r["lab"]["무리"] == "사람" and r["lab"]["정답"] + ] + got = hm.targets() + five = [set(r["셈"]["차례"][:5]) for r in have] + named = [{c["로직"] for c in hm.name_cands(r["lab"], got)} for r in have] + near = sum(bool(f & set(r["lab"]["정답"])) for f, r in zip(five, have, strict=True)) + both = sum( + bool((f | n) & set(r["lab"]["정답"])) for f, n, r in zip(five, named, have, strict=True) + ) + assert both >= near >= 122 diff --git a/resources/tester/test_m02_hopyo_match.py b/resources/tester/test_m02_hopyo_match.py index e593dd4d1..adc1a1ba7 100644 --- a/resources/tester/test_m02_hopyo_match.py +++ b/resources/tester/test_m02_hopyo_match.py @@ -245,3 +245,26 @@ def test_뜻_낱말이_안_겹치면_후보_아님(): assert got["상태"] == "없음" and got["top"] == [] fill = {"이름": "구조물되메우기(인력100%)", "규격": "", "단위": "㎥", "갈래": "단가산출"} assert hm.candidates(fill, "로직")[0]["이름"] == "되메우기" # 한쪽 낱말에 듦 = 겹침 + + +# ── 이름 닮은 로직(358-1 · 옛 324 데스크탑 다섯 그대로) ───────────────────── +def test_이름후보_옛_데스크탑_다섯(): + fill = {"이름": "구조물되메우기(인력100%)", "규격": "", "단위": "㎥", "갈래": "단가산출"} + got = [c["로직"] for c in hm.name_cands(fill)] + assert got == ["GF000113", "GF000194", "GC000108", "GC000109", "GC000451"] # 되메우기 다섯 + dowel = {"이름": "다웰바설치", "규격": "Φ32×1000", "단위": "EA", "갈래": "단가산출"} + assert [c["로직"] for c in hm.name_cands(dowel)][:3] == ["GC000226", "GF000201", "GF000202"] + soil = {"이름": "잔토처리(인력100%)", "규격": "", "단위": "㎥", "갈래": "단가산출"} + assert hm.name_cands(soil) == [] # 뜻 낱말 안 겹치면 후보 아님 그대로 + same = {"이름": "되메우기", "규격": "", "단위": "㎥", "갈래": "단가산출"} + assert hm.name_cands(same)[0] == {**hm.name_cands(same)[0], "로직": "GF000113", "점수": 9} + + +def test_이름후보_뜻겹침_출처_표시(client): + _ready(client) + dig = _hopyo(client, 3)["구조물터파기(인력100%)"] + assert dig["이름후보"] and all({"뜻겹침", "출처같음", "책"} <= set(c) for c in dig["이름후보"]) + assert dig["후보"][0]["출처같음"] is True and dig["후보"][0]["책"] == "건설품셈" # 3-2-1 + assert [c["로직"] for c in dig["후보"]][0] == "GC000083" # 차례 · 판정 그대로(표시만) + mat = _hopyo(client, 1)["레미콘(25-21-120)"] + assert "이름후보" not in mat # 자재 줄은 로직 이름후보 없음 diff --git a/resources/tester/test_m02_hopyo_screen.py b/resources/tester/test_m02_hopyo_screen.py index 0a421e7fd..fd729e0b4 100644 --- a/resources/tester/test_m02_hopyo_screen.py +++ b/resources/tester/test_m02_hopyo_screen.py @@ -136,7 +136,16 @@ def test_확인_창_체크한_줄만_몸(): def test_고르기_창_도우미(): got = _check()["choose"] - assert got["hint"] == ["6-1-1", "고임돌"] # 인용 조항 번호 → 없으면 품명 첫 뜻 낱말 + assert got["hint"] == ["레미콘타설", "고임돌"] # 품명 첫 뜻 낱말(358-1 · 조항 번호 아님) + assert got["groups"]["groups"] == [ + ["1위", False, ["A"]], + ["출처 같음", False, ["C"]], + ["이름 닮은 로직", False, ["E"]], + ["숫자만 닮음", True, ["D", "B"]], # 뜻 낱말 안 겹치는 「후보」 = 끝 + ] + assert got["groups"]["text"][:2] == ["되메우기 · A · 산림", "되메우기 · C · 건설"] + assert got["groups"]["material"] == ["1위", "그 밖 후보"] + assert got["groups"]["pick"] == [True, False, True] # 재료비 있는 단가산출 줄도 자재 검색 assert got["picked"] == [ {"로직": "GC000206", "입력값": {}, "배": 1, "몫": None}, {"로직": "GF000211", "입력값": {}, "배": 0.1, "몫": 0.1}, From 73b337f1529792e1b88640ec9920aca1ed290ba7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 21:07:12 +0900 Subject: [PATCH 2/2] =?UTF-8?q?=EF=BB=BFfix(B05):=20=EB=B0=B0=EC=88=98?= =?UTF-8?q?=EC=9C=A0=EC=97=AD=20=EB=B2=84=ED=8A=BC=20=EA=B8=80=20=EB=AA=85?= =?UTF-8?q?=EC=82=AC=ED=98=95=20=E2=80=94=20=EC=9C=A0=EC=97=AD=20=EC=9E=AC?= =?UTF-8?q?=EB=B6=84=ED=95=A0=20=C2=B7=20=EA=B4=80=20=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?(359-3=20=EB=8D=A4=20=C2=B7=20=EC=9A=94=EA=B5=AC=20235-27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn --- .../tester/test_359_3_b05_drainage_btn_noun.py | 13 +++++++++++++ ui_template/ui_template_locale_b2.ts | 4 ++-- 2 files changed, 15 insertions(+), 2 deletions(-) create mode 100644 resources/tester/test_359_3_b05_drainage_btn_noun.py diff --git a/resources/tester/test_359_3_b05_drainage_btn_noun.py b/resources/tester/test_359_3_b05_drainage_btn_noun.py new file mode 100644 index 000000000..2f2e29e60 --- /dev/null +++ b/resources/tester/test_359_3_b05_drainage_btn_noun.py @@ -0,0 +1,13 @@ +"""359-3 덤(요구 235-27) — B05 배수유역 버튼 글 명사형.""" + +from pathlib import Path + +LOCALE = Path(__file__).resolve().parents[2] / "ui_template" / "ui_template_locale_b2.ts" + + +def test_drainage_buttons_are_nouns(): + text = LOCALE.read_text(encoding="utf-8") + assert 'B05_Drainage_Btn_Analyze: ["유역 재분할",' in text + assert 'B05_Drainage_Btn_DeleteSelected: ["관 삭제",' in text + assert "유역 다시 나누기" not in text + assert "선택한 관 삭제" not in text diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 69df77e87..677cdb4b9 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -32,12 +32,12 @@ export const ui_locales_b2 = { ], // 버튼 이름만으로 무엇이 바뀌는지 알기 어려워 동작을 이름·툴팁에 함께 적는다 // (2026-08-19 사용자 지시 4). - B05_Drainage_Btn_Analyze: ["유역 다시 나누기", "Recompute sub-basins"], + B05_Drainage_Btn_Analyze: ["유역 재분할", "Recompute sub-basins"], B05_Drainage_Btn_Analyze_Tip: [ "B04에서 분석해 둔 배수유역을 불러와, 지금 배치된 배관을 기준으로 세부유역(관이 담당하는 구역)을 다시 나눕니다. 관이 부족한 구간은 자동으로 보충합니다. B04 분석 결과가 없으면 B04에서 먼저 실행해야 합니다.", "Reloads the B04 drainage analysis and re-splits sub-basins around the current culverts, adding culverts where spacing requires. Run the analysis in B04 first if none exists.", ], - B05_Drainage_Btn_DeleteSelected: ["선택한 관 삭제", "Delete selected culvert"], + B05_Drainage_Btn_DeleteSelected: ["관 삭제", "Delete selected culvert"], B05_Drainage_Btn_DeleteSelected_Tip: [ "지도에서 고른 배관 한 개를 지웁니다. 관을 먼저 눌러 고른 뒤에 쓸 수 있습니다.", "Removes the culvert selected on the map. Select a culvert marker first.",