diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css b/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css index 134403225..8e771bad7 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css @@ -227,3 +227,24 @@ .m02-hopyo-sheet__result:empty { display: none; } + +/* 고르기 창 근거(330) — 후보 꼬리표 · 엑셀 근거 줄 */ +.m02-hopyo__basis { + margin-left: var(--spacing-8); + padding: 0 var(--spacing-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + color: var(--color-text-secondary); + font-size: var(--text-caption); + white-space: nowrap; +} + +.m02-hopyo__basis.is-ok { + border-color: var(--color-success); + color: var(--color-success); +} + +.m02-hopyo__source { + padding-bottom: var(--spacing-8); + border-bottom: 1px solid var(--color-border); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts index c4ef47641..12312fba2 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts @@ -59,6 +59,22 @@ export interface HopyoView extends HopyoFile { 맞춤: [number, number]; } +/** 후보가 단가산출 근거와 맞는 것(330-2) — 맞은 조항 글 · 겹친 직종 · 단위 같음 · 점수 · 까닭 꼬리표 */ +export interface HopyoBasisHit { + 조항: string[]; + 직종: string[]; + 단위: boolean; + 점수: number; + 까닭: string; +} + +/** 단산 줄 근거(330-1) — 단가산출 블록의 출처([책, 번호]) · 직종 · 엑셀 행 [시작, 끝] */ +export interface HopyoLineBasis { + 출처: [string, string][]; + 직종: string[]; + 행: [number, number]; +} + /** 맞춤 대상 — 로직 · 자재 · 다른 호표 */ export interface HopyoTarget { 로직?: string; @@ -68,6 +84,8 @@ export interface HopyoTarget { 규격?: string; 단위?: string | null; 점수?: number; + /** 근거 맞춤(330 · 옛 파일 · 근거 없는 줄은 없음) */ + 근거?: HopyoBasisHit; } export interface HopyoMatch { @@ -92,6 +110,8 @@ export interface HopyoLine { 금액: Money; 비고: string; 맞춤: HopyoMatch; + /** 단가산출 근거(330 · 「단산N참조」 줄만) */ + 근거?: HopyoLineBasis; } export interface HopyoOne extends HopyoFile { diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts index 3aa5fe2c8..63a5eef75 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts @@ -3,7 +3,8 @@ * 일위대가 실무자료 본 화면(PLAN 324-4) — 파일 하나: 파일 · 시트 · 호표 수 · 검산 · 맞춤 n/m · 호표 표 + 찾기 · * [지우기] / 호표 하나: 줄 표(갈래 · 품명 · 규격 · 수량 · 단위 · 엑셀 단가 · 맞춤 · 대상 · 입력값 · 환산 · * [고르기] [확정] [빼기]) · [같음 모두 확정] · 빠진 줄 · [사전조합 만들기] → 값 비교 · 다시 씀이면 [새로 만들기] · - * [엑셀로 보기] · [엑셀에서 보기](329 엑셀 보기 창 · 늦게 불러옴 · 호표 화면은 그 호표 고른 채). + * [엑셀로 보기] · [엑셀에서 보기](329 엑셀 보기 창 · 늦게 불러옴 · 호표 화면은 그 호표 고른 채) · + * 고르기 창 후보 근거 꼬리표 · 엑셀 근거 줄 + [엑셀에서 보기](단가산출 그 행) · [근거 맞음 모두 확정](330). * 맞춤 · 만들기는 서버(`M02_Hopyo_Match` · `M02_Hopyo_Build`) · 쓰기 단추는 층 쓰기 권한만. * ========================================================================== */ @@ -17,9 +18,11 @@ import { readHopyo, readHopyoOne, saveHopyoMemory, + type HopyoBasisHit, type HopyoBuilt, type HopyoGuess, type HopyoLine, + type HopyoLineBasis, type HopyoMemory, type HopyoOne, type HopyoScope, @@ -114,6 +117,42 @@ export function memoryOf( return null; } +/** 근거 맞음 — 조항 · 직종 · 단위 모두 맞음(330 [근거 맞음 모두 확정]) */ +export const basisOk = (b: HopyoBasisHit | undefined): boolean => + !!b && b.조항.length > 0 && b.직종.length > 0 && b.단위; + +/** 후보 근거 꼬리표 — 「조항 건설 공통 3-2-1 · 직종 특별인부 · 단위 같음」 · 근거 없으면 "" */ +export const basisTag = (b: HopyoBasisHit | undefined): string => + b + ? [ + b.조항.length ? `조항 ${b.조항.join(", ")}` : "", + b.직종.length ? `직종 ${b.직종.join(", ")}` : "", + b.단위 ? "단위 같음" : "", + ] + .filter(Boolean) + .join(" · ") + : ""; + +/** 엑셀 근거 줄 — 「출처 건설표준품셈 3-2-1 · 직종 특별인부 · 단가산출 93–101행」 · 없으면 "" */ +export const sourceText = (b: HopyoLineBasis | undefined): string => + b + ? [ + b.출처.length ? `출처 ${b.출처.map((x) => x.join(" ").trim()).join(", ")}` : "", + b.직종.length ? `직종 ${b.직종.join(", ")}` : "", + `단가산출 ${b.행[0] === b.행[1] ? b.행[0] : `${b.행[0]}–${b.행[1]}`}행`, + ] + .filter(Boolean) + .join(" · ") + : ""; + +/** [근거 맞음 모두 확정] 대상 — 「후보」 줄의 지금 대상이 근거 맞으면 그것 · 아니면 근거 맞는 첫 후보 · 없으면 null */ +export function basisPick(line: HopyoLine): HopyoTarget | null { + const m = line.맞춤; + if (m.상태 !== "후보") return null; + if (m.대상 && basisOk(m.대상.근거)) return m.대상; + return (m.후보 ?? []).find((c) => basisOk(c.근거)) ?? null; +} + export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement { const view = el("div", { className: "m02-hopyo", attrs: { "data-hopyo-view": key } }); let nav = 0; // 늦게 온 응답 버림 @@ -236,9 +275,19 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement const all = createButton({ label: `같음 모두 확정 (${same.length})`, size: "sm" }); all.disabled = !same.length; all.addEventListener("click", () => void confirmAll(no, same)); + tools.push(all); + // 근거(330) — 옛 파일 · 근거 없는 호표면 단추 없음 + if (h.줄.some((x) => x.근거 || x.맞춤.후보?.some((c) => c.근거))) { + const fit = h.줄.filter((x) => basisPick(x)); + const ok = createButton({ label: `근거 맞음 모두 확정 (${fit.length})`, size: "sm" }); + ok.title = "조항 · 직종 · 단위가 모두 맞는 후보만"; + ok.disabled = !fit.length; + ok.addEventListener("click", () => void confirmBasis(no, fit)); + tools.push(ok); + } const make = createButton({ label: "사전조합 만들기", variant: "filled", size: "sm" }); make.addEventListener("click", () => void build(no, false)); - tools.push(all, make); + tools.push(make); } const missing = h.줄.filter((x) => !x.맞춤.들어감); view.replaceChildren( @@ -330,7 +379,7 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement variant: "ghost", size: "sm", onClick: () => - choose(line, (t) => + choose(line, got.파일, (t) => guessHopyo(opt.scope, key, got.호표.번, line.차례, t).then( (g) => (picked.set(line.차례, g), repaint()), fail, @@ -378,8 +427,8 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement }); } - /** 대상 고르기 — 후보 5 + 다른 로직 · 자재 찾기 */ - function choose(line: HopyoLine, done: (t: HopyoTarget) => void): void { + /** 대상 고르기 — 엑셀 근거 줄 · 후보 5(근거 꼬리표) + 다른 로직 · 자재 찾기 */ + function choose(line: HopyoLine, 파일: string, done: (t: HopyoTarget) => void): void { const material = MATERIAL.has(line.갈래); openModal({ title: `고르기 — ${line.이름} ${line.규격}`.trim(), @@ -390,14 +439,48 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement close(); done(t); }; - const list = (line.맞춤.후보 ?? []).map((c) => - createButton({ + const list = (line.맞춤.후보 ?? []).map((c) => { + const button = createButton({ label: `${targetText(c)} · ${c.로직 ?? c.자재 ?? ""}`, variant: "ghost", size: "sm", onClick: () => pick(c), - }), - ); + }); + const tag = basisTag(c.근거); + if (tag && c.근거) + button.append( + el("span", { + className: `m02-hopyo__basis${basisOk(c.근거) ? " is-ok" : ""}`, + text: tag, + attrs: { title: `${c.근거.까닭} · 점수 ${c.근거.점수}` }, + }), + ); + return button; + }); + const basis = line.근거; + const source = basis + ? [ + el("div", { + className: "m02-hopyo__bar m02-hopyo__source", + children: [ + note(`엑셀 근거 — ${sourceText(basis)}`), + createButton({ + label: "엑셀에서 보기", + variant: "ghost", + size: "sm", + onClick: () => + openSheet({ + scope: opt.scope, + key, + 파일, + canWrite: opt.canWrite, + 단산행: basis.행, + }), + }), + ], + }), + ] + : []; const more = createButton({ label: material ? "다른 자재 찾기…" : "다른 로직 찾기…", size: "sm", @@ -408,6 +491,7 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement }, }); body.append( + ...source, el("p", { className: "m02-hopyo__note", text: list.length ? "후보" : "가까운 것 없음" }), el("div", { className: "m02-hopyo__cands", children: list }), more, @@ -434,6 +518,24 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement await remember(no, body); } + /** 근거 맞는 후보로 확정 — 지금 대상이 아니면 입력값 · 환산을 서버에서 다시 읽음 */ + async function confirmBasis(no: number, lines: HopyoLine[]): Promise { + const body: Record = {}; + for (const x of lines) { + const c = basisPick(x); + const m = x.맞춤; + if (!c) continue; + const g = + c === m.대상 + ? { 대상: c, 입력값: m.입력값, 환산: m.환산 } + : await guessHopyo(opt.scope, key, no, x.차례, c).catch(() => null); + const one = g && memoryOf(g.대상, g.입력값, g.환산?.배 ?? null); + if (one) body[m.열쇠] = one; + } + if (!Object.keys(body).length) return showToast("확정할 줄 없음 — 환산을 못 읽음", "warning"); + await remember(no, body); + } + async function build(no: number, fresh: boolean): Promise { try { const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: key, 번: no }; diff --git a/resources/tester/helper_m02_hopyo_pick.cjs b/resources/tester/helper_m02_hopyo_pick.cjs index 1a389f69f..78c8b60a9 100644 --- a/resources/tester/helper_m02_hopyo_pick.cjs +++ b/resources/tester/helper_m02_hopyo_pick.cjs @@ -88,6 +88,34 @@ function loadTs(file) { view.builtWord({ 다시씀: true }, true), ], }, + basis: (() => { + const hit = { + 조항: ["건설 공통 3-2-1"], + 직종: ["특별인부"], + 단위: true, + 점수: 1.65, + 까닭: "조항", + }; + const half = { ...hit, 직종: [] }; + const line = (상태, 대상, 후보) => ({ 맞춤: { 상태, 대상, 후보 } }); + const a = { 로직: "GF000101", 근거: half }; + const b = { 로직: "GC000083", 근거: hit }; + return { + ok: [hit, half, { ...hit, 단위: false }, undefined].map(view.basisOk), + tag: [hit, half, undefined].map(view.basisTag), + source: [ + view.sourceText({ 출처: [["건설표준품셈", "3-2-1"]], 직종: ["특별인부"], 행: [93, 101] }), + view.sourceText({ 출처: [], 직종: [], 행: [7, 7] }), + view.sourceText(undefined), + ], + pick: [ + line("후보", a, [a, b]), + line("후보", b, [b]), + line("같음", b, [b]), + line("후보", a, [a]), + ].map((x) => view.basisPick(x)?.로직 ?? null), + }; + })(), sheet: { at: [19, 20, 22, 24, 25, 30, 31].map((row) => sheet.hopyoAtRow(spans, row)), span: sheet.spanText([20, 24], sheet.lastColumn({ 칸: { A1: {}, N24: {} }, 열: { O: {} } })), diff --git a/resources/tester/test_m02_hopyo_screen.py b/resources/tester/test_m02_hopyo_screen.py index 804e09399..c844f2d7d 100644 --- a/resources/tester/test_m02_hopyo_screen.py +++ b/resources/tester/test_m02_hopyo_screen.py @@ -3,6 +3,7 @@ - 고르기 창 덧줄(Node · fetch 대역): 이 층 · 위 층 호표 줄(맞춤 n/m) · 종류 고르기 · 고르면 문서 층에 만들기 길 → `lib.pick` 참조 줄(별칭 = 호표 품명) · 프로젝트 층엔 없음 · 본 화면 글 도우미 +- 고르기 창 근거(330-3): 꼬리표 · 엑셀 근거 줄 · [근거 맞음 모두 확정] 대상 - 엑셀 보기 창(329): 행 → 호표 · 호표 영역 A1 · 격자 길 · 여는 자리 넷 · 늦게 불러옴 - 소스: 좌측 판 자리(사전조합 아래 · 구조물 조합 층 · 쓰기 권한 없으면 올리기 꺼짐 · 숨은 file 입력) · Labor_Detail 줄 수 그대로 · 새 파일 이름 · 700 줄 · 색 = 공용 토큰 @@ -188,3 +189,23 @@ def test_엑셀_보기_창_여는_자리와_늦게_불러옴(): r'^import \{[^}]*\} from "\.\./A00_Common/spreadsheet/spreadsheet"', sheet, re.M ) assert "readOnly: true" in sheet and "wide: true" in sheet + + +def test_고르기_창_근거_꼬리표와_근거_맞음(): + basis = _run()["basis"] + assert basis["ok"] == [True, False, False, False] # 조항 · 직종 · 단위 모두 + assert basis["tag"] == [ + "조항 건설 공통 3-2-1 · 직종 특별인부 · 단위 같음", + "조항 건설 공통 3-2-1 · 단위 같음", + "", + ] + assert basis["source"] == [ + "출처 건설표준품셈 3-2-1 · 직종 특별인부 · 단가산출 93–101행", + "단가산출 7행", + "", + ] + # 지금 대상이 근거 안 맞으면 근거 맞는 후보 · 「후보」 줄만 · 근거 맞는 것 없으면 없음 + assert basis["pick"] == ["GC000083", "GC000083", None, None] + view = _text("M02_MasterTemplete_Hopyo_View.ts") + assert "단산행: basis.행" in view and "근거 맞음 모두 확정" in view + assert "x.근거 || x.맞춤.후보?.some((c) => c.근거)" in view # 옛 파일 = 단추 없음