Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts
T
eomsangdonandClaude Opus 5.5 1a029141f8 fix(일위대가실무): 330 덧고침 2 — 화면 근거 맞음 판정을 조항 같음 · 단위 · 직종(줄 직종이 보통인부뿐 · 없으면 안 봄)으로
- Hopyo_View basisOk(후보 근거, 줄 근거) — 앞 마디 같음은 근거 맞음 아님 · 엑셀 직종 없는 줄(기계)도 조항 같음 + 단위면 맞음 · basisPick · 고르기 창 꼬리표가 줄 근거를 넘김
- 시험 screen(앞 마디 · 보통인부뿐 · 직종 없음) · seam(북부청 실제 응답 1순위 근거 맞음 123 = 서버 기준 · 앞 마디 0)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-15 12:57:25 +09:00

615 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* M02_MasterTemplete_Hopyo_View.ts
* 일위대가 실무자료 본 화면(PLAN 324-4) — 파일 하나: 파일 · 시트 · 호표 수 · 검산 · 맞춤 n/m · 호표 표 + 찾기 ·
* [지우기] / 호표 하나: 줄 표(갈래 · 품명 · 규격 · 수량 · 단위 · 엑셀 단가 · 맞춤 · 대상 · 입력값 · 환산 ·
* [고르기] [확정] [빼기]) · [같음 모두 확정] · 빠진 줄 · [사전조합 만들기] → 값 비교 · 다시 씀이면 [새로 만들기] ·
* [엑셀로 보기] · [엑셀에서 보기](329 엑셀 보기 창 · 늦게 불러옴 · 호표 화면은 그 호표 고른 채) ·
* 고르기 창 후보 근거 꼬리표 · 엑셀 근거 줄 + [엑셀에서 보기](단가산출 그 행) · [근거 맞음 모두 확정](330).
* 맞춤 · 만들기는 서버(`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 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");
const fail = (error: unknown): void => showToast(`실패 — ${why(error)}`, "error");
/** 엑셀 보기 창(329) — 열 때 불러옴 */
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 =>
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(),
children: [
el("thead", {
children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })],
}),
body,
],
}),
],
});
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; // 늦게 온 응답 버림
let query = "";
async function load<T>(get: () => Promise<T>, retry: () => void): Promise<T | null> {
const my = ++nav;
const stop = showLoading(view);
try {
const got = await get();
return my === nav ? got : null;
} catch (error) {
if (my === nav) view.replaceChildren(loadingError(why(error), retry));
return null;
} finally {
stop();
}
}
/* ── 파일 하나 — 호표 표 ─────────────────────────────────────── */
async function drawFile(): Promise<void> {
const file = await load(
() => readHopyo(opt.scope, key),
() => void drawFile(),
);
if (!file) return;
const search = el("input", {
className: "ui-input ui-input--sm m02-hopyo__search",
attrs: { type: "search", placeholder: "호표 · 품명 · 규격 찾기", value: query },
}) as HTMLInputElement;
const body = el("tbody");
const paint = (): void => {
const q = search.value.trim().toLowerCase();
query = search.value;
body.replaceChildren(
...file.호표
.filter((h) => !q || `제${h.번}호표 ${h.이름} ${h.규격}`.toLowerCase().includes(q))
.map((h) => {
const tr = el("tr", {
className: "m02-hopyo__row",
attrs: { "data-hopyo-no": String(h.번) },
children: [
td(`제${h.번}호표`),
td(h.이름),
td(h.규격),
td(h.단위),
td(won(h.금액.계), "m02-hopyo__num"),
td(`${h.맞춤[0]} / ${h.맞춤[1]}`, "m02-hopyo__num"),
],
});
tr.addEventListener("click", () => void drawOne(h.번));
return tr;
}),
);
};
search.addEventListener("input", paint);
paint();
const wrong = file.검산.어긋남;
view.replaceChildren(
el("div", {
className: "m02-hopyo__bar",
children: [
el("strong", { text: file.파일 }),
note(`시트 ${file.시트}`),
note(`호표 ${file.호표수}`),
note(wrong.length ? `검산 어긋남 ${wrong.length}` : "검산 맞음"),
note(`맞춤 ${file.맞춤[0]} / ${file.맞춤[1]}`),
createButton({
label: "엑셀로 보기",
size: "sm",
onClick: () =>
openSheet({ scope: opt.scope, key, 파일: file.파일, canWrite: opt.canWrite }),
}),
...(opt.canWrite ? [dropButton(file)] : []),
],
}),
...(wrong.length ? [el("p", { className: "m02-hopyo__warn", text: wrong.join("\n") })] : []),
search,
grid(["호표", "품명", "규격", "단위", "엑셀 단가", "맞춤"], body),
);
}
const dropButton = (file: HopyoView): HTMLButtonElement =>
createButton({
label: "지우기",
variant: "danger",
size: "sm",
onClick: async () => {
const ask = `실무자료 「${file.파일}」을 지움 — 만든 사전조합 · 맞춤 기억은 그대로`;
if (!(await showConfirmDialog(ask, "지우기"))) return;
try {
await deleteHopyo(opt.scope, key);
showToast("지움", "success");
opt.onDeleted();
} catch (error) {
fail(error);
}
},
});
/* ── 호표 하나 — 줄 맞춤 ────────────────────────────────────── */
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.맞춤.들어감);
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,
],
}),
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;
}