feat(M02): 338-3c 확인 창 · 고르기 창 · 별칭 장부 화면 — 체크한 줄만 [확인하고 사전조합 만들기] · 옛 즉시 확정 · 바로 만들기 없앰

- 새 Hopyo_Check(확인 창 · 본 칸 · 모달) — 상태 색 · 체크 제안 · 우리 로직 여럿 · 배 · 몫 · 옵션 · 품 비교 · 뺌 · 원문 보기 · 캐시 m02-hopyo-check · 셈 중 3 초 다시 읽음 · 최신 응답만
- 새 Hopyo_Choose(고르기 창 · 후보 판정 · 여럿 고름 · 몫 · 찾기 글 미리 넣음) · 새 Hopyo_Ledger(장부 화면 · 고친 것 캐시 · [저장] 때만 PUT)
- 고침 View(호표 하나 = 확인 창) · Sheet(펼침 뒤 한 번 · 하위 호표 먼저) · Pick(확인된 것 바로 참조 · 아니면 확인 창) · Api(3-3 꼴 · 만들기 몸 · 장부 길) · Side([별칭 장부]) · css · Labor_Pick(query)
- 없앰 saveHopyoMemory · [확정] · [같음 모두 확정] · [근거 맞음 모두 확정] · 바로 만들기
- 시험 test_m02_hopyo_screen.py 새로 · helper_m02_hopyo_check.cjs 새 · pick · seam 도우미 손질

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
This commit is contained in:
2026-10-15 23:37:24 +09:00
co-authored by Claude Opus 5.5
parent bf5d130e32
commit 01d3b0d2f2
15 changed files with 2204 additions and 764 deletions
@@ -1,58 +1,31 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_View.ts
* 일위대가 실무자료 본 화면(PLAN 324-4) — 파일 하나: 파일 · 시트 · 호표 수 · 검산 · 맞춤 n/m · 호표 표 + 찾기 ·
* [지우기] / 호표 하나: 줄 표(갈래 · 품명 · 규격 · 수량 · 단위 · 엑셀 단가 · 맞춤 · 대상 · 입력값 · 환산 ·
* [고르기] [확정] [빼기]) · [같음 모두 확정] · 빠진 줄 · [사전조합 만들기] → 값 비교 · 다시 씀이면 [새로 만들기] ·
* [엑셀로 보기] · [엑셀에서 보기](329 엑셀 보기 창 · 늦게 불러옴 · 호표 화면은 그 호표 고른 채) ·
* 고르기 창 후보 근거 꼬리표 · 엑셀 근거 줄 + [엑셀에서 보기](단가산출 그 행) · [근거 맞음 모두 확정](330).
* 일위대가 실무자료 본 화면(PLAN 324-4 · 338-3) — 파일 하나: 파일 · 시트 · 호표 수 · 검산 · 제안 n/m · 호표 표 + 찾기 ·
* [지우기] · [엑셀로 보기](329 엑셀 보기 창 · 늦게 불러옴) / 호표 하나 = 확인 창(`M02_MasterTemplete_Hopyo_Check` ·
* 체크한 줄만 [확인하고 사전조합 만들기]) — 즉시 확정 · 바로 만들기 단추 없음.
* 맞춤 · 만들기는 서버(`M02_Hopyo_Match` · `M02_Hopyo_Build`) · 쓰기 단추는 층 쓰기 권한만.
* ========================================================================== */
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
import { loadingError, showLoading } from "@ui/ui_template_loading";
import { openModal } from "@ui/ui_template_modal";
import {
buildHopyo,
deleteHopyo,
guessHopyo,
readHopyo,
readHopyoOne,
saveHopyoMemory,
type HopyoBasisHit,
type HopyoBuilt,
type HopyoGuess,
type HopyoLine,
type HopyoLineBasis,
type HopyoMemory,
type HopyoOne,
type HopyoScope,
type HopyoTarget,
type HopyoView,
} from "./M02_MasterTemplete_Hopyo_Api";
import { buildHopyoCheck } from "./M02_MasterTemplete_Hopyo_Check";
import type { HopyoSheetOptions } from "./M02_MasterTemplete_Hopyo_Sheet";
import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
import { pickMaterial } from "./M02_Material_Pick";
import "./M02_MasterTemplete_Hopyo.css";
export interface HopyoViewOptions {
scope: HopyoScope;
/** 층 쓰기 권한 — 없으면 확정 · 만들기 · 지우기 단추 없음 */
/** 층 쓰기 권한 — 없으면 체크 · 만들기 · 지우기 단추 없음 */
canWrite: boolean;
/** 파일을 지웠음 — 좌측 목록 다시 */
onDeleted: () => void;
}
const STATE: Record<string, string> = {
기억: "기억",
같음: "같음",
후보: "후보",
없음: "없음",
빼기: "뺌",
호표: "호표",
"%": "% 줄",
};
const MATERIAL = new Set(["자재", "별도계상"]);
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
const won = (v: number | null | undefined): string =>
v === null || v === undefined ? "" : v.toLocaleString("ko-KR");
@@ -61,12 +34,12 @@ const fail = (error: unknown): void => showToast(`실패 — ${why(error)}`, "er
const openSheet = (o: HopyoSheetOptions): void =>
void import("./M02_MasterTemplete_Hopyo_Sheet").then((m) => m.openHopyoSheet(o), fail);
const grid = (heads: string[], body: HTMLElement, cls = ""): HTMLElement =>
const grid = (heads: string[], body: HTMLElement): HTMLElement =>
el("div", {
className: "ui-general-block__table-wrap ui-general-block__table-wrap--sticky-head",
children: [
el("table", {
className: `ui-general-block__table m02-hopyo__table ${cls}`.trim(),
className: "ui-general-block__table m02-hopyo__table",
children: [
el("thead", {
children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })],
@@ -76,87 +49,10 @@ const grid = (heads: string[], body: HTMLElement, cls = ""): HTMLElement =>
}),
],
});
const table = (heads: string[], rows: HTMLElement[]): HTMLElement =>
grid(heads, el("tbody", { children: rows }));
const td = (text: string, cls = ""): HTMLElement =>
el("td", { text, className: cls, attrs: { title: text } });
const note = (text: string): HTMLElement => el("span", { className: "m02-hopyo__note", text });
/** 대상 글 — 「이름 [단위]」 · 자재는 규격도 · 호표 = 「제N호표」 */
export const targetText = (t: HopyoTarget | null | undefined): string => {
if (!t) return "";
if (t.호표 !== undefined) return `제${t.호표}호표 ${t.단위 ? `[${t.단위}]` : ""}`.trim();
const spec = t.규격 ? ` ${t.규격}` : "";
return `${t.이름 ?? t.로직 ?? t.자재 ?? ""}${spec}${t.단위 ? ` [${t.단위}]` : ""}`;
};
/** 입력값 글 — 「이름=값」 · 못 읽은 입력 표시 */
export const inputsText = (values: Record<string, unknown>, unread: string[]): string =>
[
Object.entries(values)
.map(([k, v]) => `${k}=${String(v)}`)
.join(", "),
unread.length ? `⚠ 못 읽음: ${unread.join(", ")}` : "",
]
.filter(Boolean)
.join(" · ");
/** 만들기 결과 낱말 — [새로 만들기] = 「새로 만들어 덮음」 · 있던 것 = 「다시 씀」 · 그 밖 「만듦」 */
export const builtWord = (got: Pick<HopyoBuilt, "다시씀">, fresh: boolean): string =>
fresh ? "새로 만들어 덮음" : got.다시씀 ? "다시 씀" : "만듦";
/** 확정 한 줄 몸 — 대상 · 입력값 · 환산(0 넘는 수) · 없으면 null */
export function memoryOf(
target: HopyoTarget | null,
values: Record<string, unknown>,
times: number | null,
): HopyoMemory {
if (!target || times === null || !(times > 0)) return null;
if (target.로직) return { 로직: target.로직, 입력값: values, 환산: times };
if (target.자재) return { 자재: target.자재, 입력값: values, 환산: times };
return null;
}
/** 근거 맞음(330 [근거 맞음 모두 확정]) — 조항 같음(앞 마디 아님) · 단위 맞음 · 직종 겹침
* (줄 근거 직종이 보통인부뿐이거나 없으면 — 기계 등 — 직종은 안 봄) */
export const basisOk = (b: HopyoBasisHit | undefined, line?: HopyoLineBasis): boolean =>
!!b &&
b.까닭.startsWith("조항 같음") &&
b.단위 &&
(b.직종.length > 0 || !(line?.직종 ?? []).some((x) => x !== "보통인부"));
/** 후보 근거 꼬리표 — 「조항 건설 공통 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.대상.근거, line.근거)) return m.대상;
return (m.후보 ?? []).find((c) => basisOk(c.근거, line.근거)) ?? null;
}
export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement {
const view = el("div", { className: "m02-hopyo", attrs: { "data-hopyo-view": key } });
let nav = 0; // 늦게 온 응답 버림
@@ -223,7 +119,7 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement
note(`시트 ${file.시트}`),
note(`호표 ${file.호표수}`),
note(wrong.length ? `검산 어긋남 ${wrong.length}` : "검산 맞음"),
note(`맞춤 ${file.맞춤[0]} / ${file.맞춤[1]}`),
note(`제안 ${file.맞춤[0]} / 줄 ${file.맞춤[1]}`),
createButton({
label: "엑셀로 보기",
size: "sm",
@@ -235,7 +131,7 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement
}),
...(wrong.length ? [el("p", { className: "m02-hopyo__warn", text: wrong.join("\n") })] : []),
search,
grid(["호표", "품명", "규격", "단위", "엑셀 단가", "맞춤"], body),
grid(["호표", "품명", "규격", "단위", "엑셀 단가", "제안 / 줄"], body),
);
}
@@ -245,7 +141,7 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement
variant: "danger",
size: "sm",
onClick: async () => {
const ask = `실무자료 「${file.파일}」을 지움 — 만든 사전조합 · 맞춤 기억은 그대로`;
const ask = `실무자료 「${file.파일}」을 지움 — 만든 사전조합 · 별칭 장부는 그대로`;
if (!(await showConfirmDialog(ask, "지우기"))) return;
try {
await deleteHopyo(opt.scope, key);
@@ -257,358 +153,20 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement
},
});
/* ── 호표 하나 — 줄 맞춤 ────────────────────────────────────── */
async function drawOne(no: number, built?: HopyoBuilt & { 새로: boolean }): Promise<void> {
const got = await load(
() => readHopyoOne(opt.scope, key, no),
() => void drawOne(no),
);
if (!got) return;
const h = got.호표;
const picked = new Map<number, HopyoGuess>(); // 고른 대상(확정 전)
const back = createButton({ label: "← 호표 목록", variant: "ghost", size: "sm" });
back.addEventListener("click", () => void drawFile());
const rows = el("tbody");
const paintRows = (): void => {
rows.replaceChildren(...h.줄.map((line) => lineRow(got, line, picked, paintRows)));
};
paintRows();
const same = h.줄.filter((x) => x.맞춤.상태 === "같음" && x.맞춤.환산?.배 != null);
const tools: HTMLElement[] = [];
if (opt.canWrite) {
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(make);
}
const missing = h.줄.filter((x) => !x.맞춤.들어감);
/* ── 호표 하나 — 확인 창 ────────────────────────────────────── */
function drawOne(no: number): void {
nav += 1; // 파일 표 늦은 응답 버림
view.replaceChildren(
el("div", {
className: "m02-hopyo__bar",
children: [
back,
el("strong", { text: `제${h.번}호표 ${h.이름}` }),
note(h.규격),
note(h.단위),
note(`엑셀 단가 ${won(h.금액.계)}`),
note(`맞춤 ${h.맞춤[0]} / ${h.맞춤[1]}`),
createButton({
label: "엑셀에서 보기",
size: "sm",
onClick: () =>
openSheet({
scope: opt.scope,
key,
파일: got.파일,
canWrite: opt.canWrite,
호표: no,
}),
}),
...tools,
],
buildHopyoCheck({
scope: opt.scope,
key,
no,
canWrite: opt.canWrite,
onBack: () => void drawFile(),
}),
h.줄.length
? grid(
[
"갈래",
"품명",
"규격",
"수량",
"단위",
"엑셀 단가",
"맞춤",
"대상",
"입력값",
"환산",
"",
],
rows,
"m02-hopyo__lines",
)
: el("p", { className: "m02-hopyo__note", text: "구성 줄 없음" }),
...(missing.length ? [missingBlock(missing)] : []),
...(built ? [builtBlock(no, built)] : []),
);
}
function lineRow(
got: HopyoOne,
line: HopyoLine,
picked: Map<number, HopyoGuess>,
repaint: () => void,
): HTMLElement {
const m = line.맞춤;
const mine = picked.get(line.차례);
const target = mine?.대상 ?? m.대상;
const values = mine?.입력값 ?? m.입력값;
const conv = mine?.환산 ?? m.환산;
const fixed = ["%", "호표", "빼기"].includes(m.상태);
const times = el("input", {
className: "ui-input ui-input--sm m02-hopyo__times",
attrs: {
type: "number",
step: "any",
min: "0",
value: conv?.배 != null ? String(conv.배) : "",
},
}) as HTMLInputElement;
times.disabled = !opt.canWrite || fixed || !target;
const state = mine ? "고름" : (STATE[m.상태] ?? m.상태);
const actions: HTMLElement[] = [];
if (opt.canWrite && m.상태 === "빼기") {
actions.push(
createButton({
label: "되살림",
variant: "ghost",
size: "sm",
onClick: () => void remember(got.호표.번, { [m.열쇠]: null }),
}),
);
} else if (opt.canWrite && !["%", "호표"].includes(m.상태)) {
actions.push(
createButton({
label: "고르기",
variant: "ghost",
size: "sm",
onClick: () =>
choose(line, got.파일, (t) =>
guessHopyo(opt.scope, key, got.호표.번, line.차례, t).then(
(g) => (picked.set(line.차례, g), repaint()),
fail,
),
),
}),
createButton({
label: "확정",
size: "sm",
disabled: !target,
onClick: () => {
const body = memoryOf(target, values, times.value === "" ? null : Number(times.value));
if (!body) return showToast("대상과 0 넘는 환산을 넣어야 확정", "error");
void remember(got.호표.번, { [m.열쇠]: body });
},
}),
createButton({
label: "빼기",
variant: "ghost",
size: "sm",
onClick: () => void remember(got.호표.번, { [m.열쇠]: { 빼기: true } }),
}),
);
}
return el("tr", {
className: `m02-hopyo__line is-${m.들어감 ? "in" : "out"}`,
attrs: { "data-line": String(line.차례), "data-state": m.상태 },
children: [
td(line.갈래),
td(line.이름),
td(line.규격),
td(line.수량 === null ? "" : String(line.수량), "m02-hopyo__num"),
td(line.단위),
td(won(line.단가.계), "m02-hopyo__num"),
el("td", {
children: [el("span", { className: `m02-hopyo__state is-${m.상태}`, text: state })],
}),
td(targetText(target)),
td(inputsText(values, mine?.못읽음 ?? m.못읽음)),
el("td", {
children: [times, note(conv?.갈래 ? ` ${conv.갈래}` : "")],
}),
el("td", { className: "m02-hopyo__acts", children: actions }),
],
});
}
/** 대상 고르기 — 엑셀 근거 줄 · 후보 5(근거 꼬리표) + 다른 로직 · 자재 찾기 */
function choose(line: HopyoLine, 파일: string, done: (t: HopyoTarget) => void): void {
const material = MATERIAL.has(line.갈래);
openModal({
title: `고르기 — ${line.이름} ${line.규격}`.trim(),
closeLabel: "닫기",
dialogClass: "m02-modal m02-hopyo__choose",
mount: (body, close) => {
const pick = (t: HopyoTarget): void => {
close();
done(t);
};
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.근거, line.근거) ? " 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",
onClick: () => {
close();
if (material) pickMaterial((x) => done({ 자재: x.코드 }));
else openLogicPicker((x) => done({ 로직: x.키 }));
},
});
body.append(
...source,
el("p", { className: "m02-hopyo__note", text: list.length ? "후보" : "가까운 것 없음" }),
el("div", { className: "m02-hopyo__cands", children: list }),
more,
);
},
});
}
async function remember(no: number, 줄: Record<string, HopyoMemory>): Promise<void> {
try {
await saveHopyoMemory(opt.scope, 줄);
await drawOne(no);
} catch (error) {
fail(error);
}
}
async function confirmAll(no: number, lines: HopyoLine[]): Promise<void> {
const body: Record<string, HopyoMemory> = {};
for (const x of lines) {
const one = memoryOf(x.맞춤.대상, x.맞춤.입력값, x.맞춤.환산?.배 ?? null);
if (one) body[x.맞춤.열쇠] = one;
}
await remember(no, body);
}
/** 근거 맞는 후보로 확정 — 지금 대상이 아니면 입력값 · 환산을 서버에서 다시 읽음 */
async function confirmBasis(no: number, lines: HopyoLine[]): Promise<void> {
const body: Record<string, HopyoMemory> = {};
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<void> {
try {
const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: key, 번: no };
const got = await buildHopyo(opt.scope, from, fresh);
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);
}
}
const missingBlock = (lines: HopyoLine[]): HTMLElement =>
el("div", {
className: "m02-hopyo__block",
children: [
el("h4", { text: `빠진 줄 ${lines.length} — 사전조합에 안 들어감` }),
table(
["품명", "규격", "수량", "단위", "엑셀 금액", "까닭"],
lines.map((x) =>
el("tr", {
children: [
td(x.이름),
td(x.규격),
td(x.수량 === null ? "" : String(x.수량), "m02-hopyo__num"),
td(x.단위),
td(won(x.금액.계), "m02-hopyo__num"),
td(STATE[x.맞춤.상태] ?? x.맞춤.상태),
],
}),
),
),
],
});
function builtBlock(no: number, got: HopyoBuilt & { 새로: boolean }): HTMLElement {
const c = got.비교;
const again = got.다시씀
? [
note(
got.새로
? `사전조합 ${got.참조.키} 를 새로 만들어 덮음`
: `있던 사전조합 ${got.참조.키} 를 다시 씀`,
),
createButton({
label: "새로 만들기",
variant: "danger",
size: "sm",
onClick: async () => {
const ask = `사전조합 ${got.참조.키} 를 지금 맞춤으로 다시 만들어 덮음`;
if (await showConfirmDialog(ask, "새로 만들기")) await build(no, true);
},
}),
]
: [note(`새 사전조합 ${got.참조.키}`)];
return el("div", {
className: "m02-hopyo__block m02-hopyo__built",
attrs: { "data-built": got.참조.키 },
children: [
el("div", { className: "m02-hopyo__bar", children: again }),
note(
`값 비교 — 엑셀 ${won(c.엑셀)} · 우리 ${won(c.우리 === null ? null : Math.round(c.우리))}` +
` · 차이 ${won(c.차이)}${c.까닭 ? ` · ${c.까닭}` : ""}`,
),
],
});
}
void drawFile();
return view;
}