feat(785-6): 판정 화면 새 파일 — 줄 판 · 블록 판 · 고름 캐시 · 새 길 타입

- Hopyo_JudgeApi.ts: 785-5 길 다섯 타입 · 부름(hopyo-judge 목록 · 호표 · 블록 · check · move)
- logic/Hopyo_Picks.ts: 고름 캐시 꼴 · 제안 = 미리 체크 · 호표 제안 모두 확인 = 다 풀린 제안만 · 입력 필요 남은 줄 막음 · 옮기기 몸 = 확인한 것만
- Hopyo_Rows.ts: 시트 아래 줄 판 — 1차 판정 칩 · 갈래 · 후보 복수 고름 · 수량 · 단위 환산 칸 · 엑셀 행 뛰기 · [확인] · [옮기기]
- Hopyo_Block.ts: 블록 판 — 절마다 기초 후보 · 맡은 절 · 입력값 칸(출처 꼬리표 · 입력 필요 · 엇갈림 · 기본) · 줄 대조 · 블록 판정 · 금액 참고 · [검산]
- 사전 ui_template_locale_m2_judge.ts · 자리 CSS Hopyo_Judge.css
- 시험 test_642_6_screen.py + helper_642_6_picks.cjs(북부청 예시 1 · 2 · 3 실제 풀이로)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-04 22:41:42 +09:00
co-authored by Claude Opus 5.5
parent b2c2bb7ad3
commit 0117dbdf2e
9 changed files with 1597 additions and 0 deletions
@@ -0,0 +1,432 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_Block.ts
* 엑셀 불러오기 단산 블록 판(PLAN 785-6 · 요구 641 · 642 · 설계 642-A 2-5 · 2-6) — 줄 판 단산 줄 펼침 · 단가산출 탭 줄 판 한 몸.
* · 블록 머리 = 블록 판정(기초로 풂 · 기초+덧 · 줄 조합 · 고를 것 n) · 근거 표지 · 금액 참고(우리 · 엑셀)
* · 절마다 = 맡은 절 · 기초 후보 칩(여럿 고름) · 고른 후보마다 입력값 칸(출처 꼬리표 B1~B6 · 사용자 · 기본 ·
* 「입력 필요」 붉은 칸 · 엇갈림 고르기) · 안 씀 · 줄 대조(품 같음 · 품 다름 · 엑셀만 · 우리만 · 입력 필요) · 금액
* · 덧 줄(엑셀만) = 후보 칩 → 재료 · 인력 · 기계 고른 것
* · [검산] = 서버 셈(저장 없음 · 옛 요청 끊기) · 고름 · 입력은 캐시(`createJudgeState` = sessionStorage) — 옮김은 [옮기기] 뒤 서버
* 판정 · 금액은 서버 · 여기는 보이기 · 고르기만.
* ========================================================================== */
import { createBadge, type BadgeTone } from "@ui/ui_template_badge";
import {
createButton,
createInputField,
createSelectField,
el,
showToast,
} from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { createPanelHead } from "@ui/ui_template_panel_head";
import { createTable, type Cell } from "@ui/ui_template_table";
import { createText } from "@ui/ui_template_text";
import { createToggle } from "@ui/ui_template_toggle";
import { formatNumber, formatPrice } from "../common_util/common_util_money";
import type { HopyoScope, Money } from "./M02_MasterTemplete_Hopyo_Api";
import {
checkJudge,
readJudgeBlock,
type BaseCand,
type CandMoney,
type CheckOne,
type CheckResult,
type ExtraLine,
type PairLine,
type PickKind,
type Section,
type Solved,
} from "./M02_MasterTemplete_Hopyo_JudgeApi";
import {
bare,
basePick,
blockPicks,
confirmable,
draftKey,
emptyDraft,
missing,
parseDraft,
putBlock,
samePick,
setInput,
togglePick,
type DraftPick,
type JudgeDraft,
} from "./logic/M02_MasterTemplete_Hopyo_Picks";
import "./M02_MasterTemplete_Hopyo_Judge.css";
/** 고름 캐시 손잡이 — 파일 하나 · sessionStorage(포트별) · 바뀌면 알림 */
export function createJudgeState(file: HopyoScope, key: string) {
const k = draftKey(file.layer, file.owner, key);
let d: JudgeDraft = parseDraft(sessionStorage.getItem(k));
const subs = new Set<() => void>();
return {
file,
key,
get: (): JudgeDraft => d,
set(next: JudgeDraft): void {
d = next;
sessionStorage.setItem(k, JSON.stringify(next));
subs.forEach((f) => f());
},
clear(): void {
sessionStorage.removeItem(k);
d = emptyDraft();
subs.forEach((f) => f());
},
on(f: () => void): () => void {
subs.add(f);
return () => subs.delete(f);
},
};
}
export type JudgeState = ReturnType<typeof createJudgeState>;
const TONE: Record<string, BadgeTone> = {
"기초로 풂": "success",
"기초+덧": "success",
"줄 조합": "info",
"고를 것": "warning",
"기초 풂": "success",
"후보 여럿": "warning",
"기초 없음": "danger",
"품 같음": "success",
"품 다름": "warning",
"입력 필요": "danger",
엑셀만: "info",
우리만: "neutral",
확인: "success",
};
const badge = (text: string, at?: string): HTMLElement => {
const b = createBadge(text, TONE[text] ?? "neutral");
if (at) b.dataset[at] = text;
return b;
};
const won = (v: number | null | undefined): string =>
v === null || v === undefined ? "—" : formatPrice(Math.floor(v));
const show = (v: unknown): string =>
v === null || v === undefined ? "" : typeof v === "number" ? formatNumber(v) : String(v);
const bar = (kids: HTMLElement[]): HTMLElement =>
el("div", { className: "ui-toolbar m02-judge__wrap", children: kids });
/** 금액 한 줄 — 노무 · 재료 · 경비 · 계(없으면 까닭) */
export function moneyText(m: CandMoney | Partial<Money> | null | undefined): string {
if (!m) return "—";
if (m.계 === null && "까닭" in m) return String(m.까닭);
const x = m as Partial<Money>;
return `${won(x.노무비)} · ${won(x.재료비)} · ${won(x.경비)} = ${won(x.계)}`;
}
export interface SolvedBodyOptions {
solved: Solved;
picks: DraftPick[];
/** 고름 · 입력 바뀜 — 부르는 쪽이 캐시에 넣음 */
onPicks: (list: DraftPick[]) => void;
/** 서버 검산(없으면 단추 안 보임) */
onCheck?: (list: DraftPick[], signal: AbortSignal) => Promise<CheckResult>;
/** 엑셀 보기 단가산출 상세 그 행 */
onSheet?: (행: number) => void;
excel?: Partial<Money> | null;
readOnly?: boolean;
}
/** 입력값 표 — 엔진 입력값 + 입력 필요 + 엇갈림 칸 */
function inputTable(
opt: SolvedBodyOptions,
cand: BaseCand,
p: DraftPick,
put: (p: DraftPick) => void,
): HTMLElement {
const b = cand.바인딩;
const names = [...new Set([...Object.keys(p.입력값), ...b.입력필요, ...Object.keys(b.엇갈림)])];
const need = new Set(missing(p));
const rows: Cell[][] = names.map((name) => {
const src = p.출처[name]?.길;
const tags: HTMLElement[] = [];
if (src) {
const tag = createBadge(src === "사용자" ? L("M02_JudgeUser") : src === "기본" ? L("M02_JudgeDefault") : src, src.startsWith("B") ? "accent" : "info"); // prettier-ignore
tag.dataset.judgeSrc = src;
if (p.출처[name]?.글) tag.title = String(p.출처[name]?.글);
tags.push(tag);
}
if (need.has(name)) tags.push(badge(L("M02_JudgeNeed"), "judgeNeed"));
const split = b.엇갈림[name];
if (split) tags.push(badge(L("M02_JudgeConflict"), "judgeConflict"));
let field: HTMLElement;
if (split && !opt.readOnly) {
const picked = split.findIndex((s) => s.값 === p.입력값[name]);
const sel = createSelectField({
options: [{ value: "", text: "—" }].concat(split.map((s, i) => ({ value: String(i), text: `${show(s.값)} (${s.길})` }))), // prettier-ignore
value: picked < 0 ? "" : String(picked),
compact: true,
size: 24,
ariaLabel: name,
onChange: (v) => put(v === "" ? setInput(p, name, "") : setInput(p, name, split[+v].값, split[+v].길)), // prettier-ignore
});
sel.select.dataset.judgeInput = name;
field = sel.root;
} else {
const f = createInputField({ compact: true, size: 24, value: show(p.입력값[name]) });
f.input.dataset.judgeInput = name;
f.input.readOnly = !!opt.readOnly;
f.input.addEventListener("change", () => put(setInput(p, name, f.input.value)));
if (need.has(name)) f.setError(L("M02_JudgeNeed"));
field = f.root;
}
const dflt = b.기본[name];
if (need.has(name) && dflt !== undefined && dflt !== null && !opt.readOnly) {
const use = createButton({ label: `${L("M02_JudgeDefault")} ${show(dflt)}`, size: 24, variant: "link", onClick: () => put(setInput(p, name, dflt, "기본")) }); // prettier-ignore
use.dataset.judgeDefault = name;
tags.push(use);
}
return [name, field, bar(tags)];
});
return createTable(
[{ label: L("M02_JudgeColInput"), width: "9em" }, { label: L("M02_JudgeColValue"), width: "12em" }, { label: L("M02_JudgeColSource") }], // prettier-ignore
rows,
{ density: "data" },
);
}
/** 줄 대조 표 */
function pairTable(opt: SolvedBodyOptions, lines: PairLine[]): HTMLElement {
const rows: Cell[][] = lines.map((x) => {
const at = x.엑셀?.행;
const jump =
at && opt.onSheet
? createButton({ label: L("M02_JudgeSheet"), size: 24, variant: "link", onClick: () => opt.onSheet!(at) }) // prettier-ignore
: "";
return [
x.엑셀?.묶음 ?? x.우리?.종류 ?? "",
x.엑셀 ? `${x.엑셀.이름} ${show(x.엑셀.품)}` : "—",
x.우리 ? `${x.우리.이름} ${show(x.우리.품)}${x.우리.까닭 ? ` · ${x.우리.까닭}` : ""}` : "—",
bar([badge(x.판정, "judgePair"), ...(x.표지 ? [createBadge(x.표지, "neutral")] : [])]),
jump,
];
});
return createTable(
[
{ label: L("M02_JudgeColGroup"), width: "48px" },
{ label: L("M02_JudgeColExcel") },
{ label: L("M02_JudgeColOurs") },
{ label: L("M02_JudgeColPair") },
{ label: L("M02_JudgeSheet"), width: "52px" },
],
rows,
{ density: "data" },
);
}
/** 덧 줄 후보 → 고른 것 */
function extraPick(x: ExtraLine, c: NonNullable<ExtraLine["후보"]>[number]): DraftPick {
const 갈래: PickKind = c.자재 ? "재료" : c.노임 ? "인력" : "기계";
const 키 = c.자재 ?? c.노임 ?? c.키 ?? null;
return { 갈래, 키, 이름: c.이름 ?? x.이름, 규격: c.규격 ?? "", 단위: c.단위 ?? "", 입력값: {}, 출처: {}, 몫: null }; // prettier-ignore
}
/** 블록 풀이 몸 — 상태(고름 · 검산 결과)를 쥐고 바뀌면 다시 그림 */
export function solvedBody(opt: SolvedBodyOptions): {
root: HTMLElement;
set: (picks: DraftPick[]) => void;
} {
const root = el("div", {
className: "m02-judge",
attrs: { "data-judge-solved": String(opt.solved.번) },
});
let picks = opt.picks;
let checked: Record<string, CheckOne> = {};
let ask: AbortController | null = null;
const put = (list: DraftPick[]): void => {
picks = list;
checked = {};
opt.onPicks(list);
draw();
};
const swap = (old: DraftPick, next: DraftPick): void =>
put(picks.map((x) => (samePick(x, old) ? next : x)));
async function check(): Promise<void> {
if (!opt.onCheck) return;
ask?.abort();
ask = new AbortController();
try {
const got = await opt.onCheck(picks, ask.signal);
checked = Object.fromEntries(got.고른.map((c) => [String(c.키), c]));
draw();
} catch (e) {
if ((e as Error).name !== "AbortError") showToast((e as Error).message, "error");
}
}
function candBox(sec: Section, cand: BaseCand): HTMLElement | null {
const p = picks.find((x) => x.갈래 === "기초" && x.키 === cand.키);
if (!p) return null;
const got = checked[cand.키];
const box = el("div", { className: "m02-judge__cand", attrs: { "data-judge-cand": cand.키 } });
const head = [createText(cand.이름, "muted"), badge(got?.판정 ?? cand.판정, "judgeVerdict")];
box.append(bar(head));
box.append(inputTable(opt, cand, p, (n) => swap(p, n)));
if (cand.바인딩.안씀.length)
box.append(createText(`${L("M02_JudgeUnused")} — ${cand.바인딩.안씀.join(" · ")}`, "hint"));
box.append(pairTable(opt, got?.줄 ?? cand.줄));
const money = el("span", { text: `${L("M02_JudgeOursMoney")} ${moneyText(got?.금액 ?? cand.금액)}` }); // prettier-ignore
money.dataset.judgeMoney = cand.키;
box.append(bar([money]));
return box;
}
function secBox(sec: Section, i: number): HTMLElement {
const box = el("div", { className: "m02-judge__sec", attrs: { "data-judge-sec": String(i) } });
const title = `${L("M02_JudgeSection")} ${sec.번 ?? i + 1}. ${sec.제목}`;
const chips = sec.후보.map((c) => {
const on = picks.some((x) => x.갈래 === "기초" && x.키 === c.키);
const chip = createToggle({ kind: "chip", label: `${c.키} ${c.이름}`, checked: on, disabled: opt.readOnly, onChange: () => put(togglePick(picks, basePick(c, sec))) }); // prettier-ignore
chip.root.dataset.judgeBase = c.키;
return chip.root;
});
if (!chips.length) chips.push(createText(L("M02_JudgeNoBase"), "warning"));
box.append(bar([createText(title, "muted", 14), badge(sec.판정, "judgeSec")]), bar(chips));
for (const c of sec.후보) {
const cb = candBox(sec, c);
if (cb) box.append(cb);
}
return box;
}
function extraBox(): HTMLElement | null {
if (!opt.solved.덧.length) return null;
const rows: Cell[][] = opt.solved.덧.map((x) => {
const chips = (x.후보 ?? []).slice(0, 4).map((c) => {
const p = extraPick(x, c);
const on = picks.some((y) => samePick(y, p));
const chip = createToggle({ kind: "chip", label: `${p.이름} ${p.규격 ?? ""}`.trim(), checked: on, disabled: opt.readOnly, onChange: () => put(togglePick(picks, p)) }); // prettier-ignore
chip.root.dataset.judgeExtra = String(p.키);
return chip.root;
});
return [x.절, x.이름, x.방법 ?? "", bar(chips)];
});
const box = el("div", { className: "m02-judge__sec", attrs: { "data-judge-extra": "" } });
box.append(createText(L("M02_JudgeExtra"), "muted", 14));
box.append(createTable([{ label: L("M02_JudgeSection"), width: "8em" }, { label: L("M02_JudgeColExcel") }, { label: "" }, { label: L("M02_JudgeColPick") }], rows, { density: "data" })); // prettier-ignore
return box;
}
function draw(): void {
const s = opt.solved;
const verdict = s.판정 === "고를 것" ? L("M02_JudgePickN").replace("{n}", String(s.고를수)) : s.판정; // prettier-ignore
const v = createBadge(verdict, TONE[s.판정] ?? "neutral");
v.dataset.judgeBlockVerdict = s.판정;
const ours = picks.reduce(
(a, p) => {
const c = s.절.flatMap((x) => x.후보).find((x) => x.키 === p.키);
const m = (checked[String(p.키)]?.금액 ?? c?.금액) as Partial<Money> | undefined;
return a === null || !m || m.계 === null || m.계 === undefined ? null : a + m.계;
},
picks.length ? 0 : (null as number | null),
);
const head: HTMLElement[] = [v, el("span", { text: `${L("M02_JudgeOursMoney")} ${won(ours)}`, attrs: { "data-judge-ours": "" } })]; // prettier-ignore
if (opt.excel) head.push(el("span", { text: `${L("M02_JudgeExcelMoney")} ${moneyText(opt.excel)}`, attrs: { "data-judge-excel": "" } })); // prettier-ignore
if (opt.onCheck) {
const go = createButton({ label: L("M02_JudgeCheck"), size: 24, variant: "ghost", disabled: !picks.length, onClick: () => void check() }); // prettier-ignore
go.dataset.judgeCheck = "";
head.push(go);
}
root.replaceChildren(bar(head), createText(L("M02_JudgeAmountNote"), "hint"));
s.절.forEach((sec, i) => root.append(secBox(sec, i)));
const extra = extraBox();
if (extra) root.append(extra);
}
draw();
return {
root,
set(list: DraftPick[]): void {
picks = list;
draw();
},
};
}
export interface JudgeBlockOptions {
host: HTMLElement;
/** 파일 층 */
file: HopyoScope;
key: string;
/** 만들 층 */
target: HopyoScope;
state: JudgeState;
no: number;
onSheet?: (행: number) => void;
onClose?: () => void;
readOnly?: boolean;
}
/** 단가산출 탭 줄 판 — 블록 하나 · [확인] = 그 블록 SK 로 옮길 것 */
export function createJudgeBlock(opt: JudgeBlockOptions): {
root: HTMLElement;
destroy: () => void;
} {
const root = el("section", {
className: "m02-judge",
attrs: { "data-judge-block": String(opt.no) },
});
root.append(createText(L("M02_JudgeLoading"), "hint"));
opt.host.replaceChildren(root);
let alive = true;
void readJudgeBlock(opt.file, opt.key, opt.no, opt.target)
.then((blk) => {
if (!alive) return;
const line = () => opt.state.get().블록[String(blk.번)];
const ok = createToggle({
kind: "chip",
label: L("M02_JudgeConfirm"),
checked: !!line()?.확인,
disabled: opt.readOnly,
onChange: (on) => {
const list = blockPicks(opt.state.get(), blk);
if (on && !confirmable(list)) {
ok.set(false);
showToast(`${L("M02_JudgeBlocked")} — ${list.flatMap(missing).join(" · ")}`, "warning");
return;
}
opt.state.set(putBlock(opt.state.get(), blk.번, { 고른: list, 확인: on }));
},
});
ok.root.dataset.judgeOk = "block";
const actions: HTMLElement[] = [ok.root];
if (opt.onClose) actions.push(createButton({ label: L("Common_Btn_Close"), size: 24, variant: "ghost", onClick: opt.onClose })); // prettier-ignore
const title = `${L("M02_JudgeBlock").replace("{n}", String(blk.번))} ${blk.이름} (${blk.단위})`;
const sub = [
blk.근거?.표지,
blk.근거?.출처.map((x) => x.join(" ")).join(" · "),
blk.부름.length ? `${L("M02_JudgeCalls")} ${blk.부름.map((n) => L("M02_JudgeHopyo").replace("{n}", String(n))).join(" · ")}` : "", // prettier-ignore
blk.만든 ? `${L("M02_JudgeMade")} ${blk.만든}` : "",
].filter(Boolean).join(" · "); // prettier-ignore
const body = solvedBody({
solved: blk,
picks: blockPicks(opt.state.get(), blk),
excel: blk.금액,
readOnly: opt.readOnly,
onSheet: opt.onSheet,
onPicks: (list) => {
opt.state.set(putBlock(opt.state.get(), blk.번, { 고른: list, 확인: false }));
ok.set(false);
},
onCheck: (list, signal) => checkJudge(opt.file, opt.key, { 블록: blk.번, 고른: list.map((p) => bare(p)) }, signal), // prettier-ignore
});
root.replaceChildren(createPanelHead({ title, sub, actions, divider: true }).root, body.root);
})
.catch((e: Error) => {
if (alive) root.replaceChildren(createText(e.message, "error"));
});
return {
root,
destroy(): void {
alive = false;
root.remove();
},
};
}
@@ -0,0 +1,33 @@
/* 엑셀 불러오기 판정 화면(PLAN 785-6) — 줄 판 · 블록 판 자리 잡기만(단추 · 표 · 배지 · 칩은 부품 상자) */
.m02-judge {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-width: 0;
}
.m02-judge__sec {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
padding: var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface-raised);
}
.m02-judge__cand {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
padding-left: var(--spacing-8);
border-left: 2px solid var(--color-accent-soft);
}
.m02-judge__cand.is-off {
opacity: 0.55;
}
.m02-judge__wrap {
flex-wrap: wrap;
}
@@ -0,0 +1,262 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_JudgeApi.ts
* 엑셀 불러오기 새 판정 길(PLAN 785-5 · 785-6 · 요구 641 · 642 · 설계 642-A 2-9) — 타입 + 부름만.
* · GET `/layers/{파일 층}/hopyo-judge/{키}` = 호표 · 블록 목록(판정 수 · 만든)
* · GET `/layers/{파일 층}/hopyo-judge/{키}/{번}` = 호표 줄 판(785-3 줄 판 + 단산 줄 `풀이`)
* · GET `/layers/{파일 층}/hopyo-judge/{키}/block/{번}` = 블록 풀이(`Solve.block` 꼴 + 이름 · 근거 · 만든)
* · POST `/layers/{파일 층}/hopyo/{키}/check` = 고른 것 검산(저장 없음 · 서버 셈)
* · POST `/layers/{만들 층}/hopyo/{키}/move` = 확인한 것 옮김(서버가 다시 셈 · 금액 · 품 칸 안 보냄)
* 판정 · 금액은 서버 — 화면은 보이기 · 고르기만(옛 `Hopyo_Api.ts` 는 785-7 에서 걷음).
* ========================================================================== */
import { call } from "./M02_MasterTemplete_Api_Fetch";
import { at, type HopyoScope, type Money } from "./M02_MasterTemplete_Hopyo_Api";
export type PickKind =
"기초" | "단가산출" | "일위대가" | "인력" | "재료" | "유가" | "기계" | "율" | "고정";
export type RowVerdict = "제안 있음" | "2차 필요" | "후보 여럿" | "후보 없음";
export type BlockVerdict = "기초로 풂" | "기초+덧" | "줄 조합" | "고를 것";
/** 값 출처 — 길 B1~B6 · 사용자 · 기본 */
export interface PickSource {
길: string;
행?: number | null;
글?: string;
}
/** 고른 것(785-3 꼴 + 배) */
export interface Pick {
갈래: PickKind;
키: string | null;
이름: string;
규격?: string;
단위?: string;
단가?: Partial<Money> | number | null;
입력값: Record<string, unknown>;
출처: Record<string, PickSource>;
몫: number | null;
비목?: string;
기준?: unknown;
별도계상?: boolean;
층?: string;
주인?: string | null;
배?: number | null;
}
export interface Binding {
입력값: Record<string, unknown>;
출처: Record<string, PickSource>;
엇갈림: Record<string, { 값: unknown; 길: string; 행: number | null }[]>;
안씀: string[];
입력필요: string[];
비움: string[];
기본: Record<string, unknown>;
}
export type PairVerdict = "품 같음" | "품 다름" | "입력 필요" | "엑셀만" | "우리만";
/** 줄 대조 — 엑셀 단위 줄 ↔ 우리 줄 */
export interface PairLine {
엑셀: { 묶음: string; 이름: string; 품: number | null; 행: number | null; 별도?: boolean } | null;
우리: { 이름: string; 종류: string; 단위: string; 품: number | null; 까닭?: string } | null;
판정: PairVerdict;
표지?: string;
}
export type CandMoney = Money | { 계: null; 까닭: string };
/** 절 기초 후보 하나 */
export interface BaseCand {
키: string;
이름: string;
바인딩: Binding;
줄: PairLine[];
셈: Partial<Record<PairVerdict, number>>;
판정: string;
금액: CandMoney;
}
export interface Section {
제목: string;
번?: string | null;
판정: "기초 풂" | "후보 여럿" | "기초 없음";
고름: string | null;
후보: BaseCand[];
몫?: { 글?: string; 값: number | null; 행?: number | null } | null;
}
/** 덧 줄(엑셀만 줄 → 덧 줄 후보 · `Units` 잇기) */
export interface ExtraLine {
절: string;
행: number | null;
이름: string;
방법?: string;
후보?: {
자재?: string;
키?: string;
노임?: string;
이름?: string;
규격?: string;
단위?: string;
}[];
}
export interface Solved {
번: number;
판정: BlockVerdict;
고를수: number;
절: Section[];
덧: ExtraLine[];
}
export interface Basis {
표지: string | null;
글: string;
출처: [string, string][];
}
export interface JudgeBlock extends Solved {
이름: string;
단위: string;
부름: number[];
근거: Basis | null;
만든: string | null;
/** 엑셀 블록 금액(참고 · 길이 실을 때만) */
금액?: Partial<Money> | null;
}
/** 호표 줄 판 한 줄(785-3 + 엑셀 글 · 단산 줄 풀이) */
export interface JudgeRow {
차례: number;
행: number | null;
갈래: PickKind[];
근거: string;
번?: number;
후보: Pick[];
판정: RowVerdict;
수량: { 엑셀: number | null; 배: number | null; 갈래: string };
이름?: string;
규격?: string;
단위?: string;
단가?: Partial<Money> | null;
풀이?: Solved | null;
}
export interface JudgeBrief {
번: number;
이름: string;
규격: string;
단위: string;
줄수: number;
판정: Partial<Record<RowVerdict, number>>;
만든: string | null;
}
export interface JudgeBlockBrief {
번: number;
이름: string;
단위: string;
부름: number[];
표지: string | null;
만든: string | null;
}
export interface JudgeList {
키: string;
파일: string;
호표: JudgeBrief[];
블록: JudgeBlockBrief[];
}
export interface JudgeOne {
번: number;
이름: string;
규격: string;
단위: string;
만든: string | null;
줄: JudgeRow[];
}
export interface CheckBody {
호표?: number;
차례?: number;
블록?: number;
고른: Pick[];
}
export interface CheckOne {
차례: number;
갈래: PickKind;
키: string | null;
입력값: Record<string, unknown>;
입력필요: string[];
줄?: PairLine[];
판정: string;
금액: CandMoney;
}
export interface CheckResult {
고른: CheckOne[];
수량?: { 엑셀: number | null; 배: number | null; 몫: number | null };
}
export interface MoveBody {
층: string;
주인?: string | null;
호표: Record<string, Record<string, Pick[]>>;
블록: Record<string, Pick[]>;
}
export interface MoveBlocked {
호표?: number;
블록?: number;
차례?: number;
까닭: string;
칸: string[];
}
export interface MoveResult {
만듦: { 번: number; 키: string; 이름: string; 다시씀?: boolean; 재사용?: boolean }[];
단가산출: { 번: number; 키: string; 이름: string }[];
막힘: MoveBlocked[];
하위: { 번: number; 키: string; 새로: boolean }[];
장부: { 씀: number };
}
const enc = encodeURIComponent;
const toQuery = (to?: HopyoScope): string =>
to ? `?to=${to.layer}${to.owner ? `&to_owner=${enc(to.owner)}` : ""}` : "";
export const readJudge = (scope: HopyoScope, key: string, to?: HopyoScope): Promise<JudgeList> =>
call(at(scope, `/hopyo-judge/${enc(key)}${toQuery(to)}`));
export const readJudgeOne = (
scope: HopyoScope,
key: string,
no: number,
to?: HopyoScope,
): Promise<JudgeOne> => call(at(scope, `/hopyo-judge/${enc(key)}/${no}${toQuery(to)}`));
export const readJudgeBlock = (
scope: HopyoScope,
key: string,
no: number,
to?: HopyoScope,
): Promise<JudgeBlock> => call(at(scope, `/hopyo-judge/${enc(key)}/block/${no}${toQuery(to)}`));
/** 검산(저장 없음) — `signal` = 옛 요청 끊기 */
export const checkJudge = (
scope: HopyoScope,
key: string,
body: CheckBody,
signal?: AbortSignal,
): Promise<CheckResult> =>
call(at(scope, `/hopyo/${enc(key)}/check`), {
method: "POST",
body: JSON.stringify(body),
signal,
});
/** 옮기기 — `target` = 만들 층 · 몸 `층` · `주인` = 파일 층 */
export const moveJudge = (target: HopyoScope, key: string, body: MoveBody): Promise<MoveResult> =>
call(at(target, `/hopyo/${enc(key)}/move`), { method: "POST", body: JSON.stringify(body) });
@@ -0,0 +1,340 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_Rows.ts
* 엑셀 불러오기 줄 판(PLAN 785-6 · 요구 641 · 642 · 설계 642-A 2-3 · 2-4 · 2-6 · 분석 642-D 2-3 · 4) — 시트 아래 `[data-hopyo-lines]`.
* · 호표 하나 = 줄마다 1차 판정 칩(제안 있음 · 2차 필요 · 후보 여럿 · 후보 없음) · 갈래 칩 · 근거 ·
* 후보 복수 고름(기초 · 단가산출 · 일위대가 · 인력 · 재료 · 유가 · 기계 · 율 · 고정 — 한 줄에 여럿) ·
* 수량 · 단위 환산 칸(`logic/Hopyo_Unit` · 같은 단위 = 숨김) · [보기] = 엑셀 그 행 · [확인]
* · 단산 줄 = [블록 풀이] 펼침(`Hopyo_Block.solvedBody` · 절마다 기초 · 입력값 칸) + 「단가산출 통째」 칩(단산N → SK)
* · 머리 [호표 제안 모두 확인](사용자 누름 · 다 풀린 제안만) · 아래 [옮기기](`createJudgeMove` · 오른쪽 판 아래)
* · 고름 · 확인 = 캐시(`createJudgeState`) · 자동 확인 · 자동 옮김 없음 · 옮김 = 서버가 정본으로 다시 셈
* ========================================================================== */
import { createBadge, type BadgeTone } from "@ui/ui_template_badge";
import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { createPanelHead } from "@ui/ui_template_panel_head";
import { tableRow, tableShell, type Cell, type Column } from "@ui/ui_template_table";
import { createText } from "@ui/ui_template_text";
import { createToggle } from "@ui/ui_template_toggle";
import { formatNumber } from "../common_util/common_util_money";
import type { HopyoScope } from "./M02_MasterTemplete_Hopyo_Api";
import { createJudgeState, solvedBody, type JudgeState } from "./M02_MasterTemplete_Hopyo_Block";
import {
checkJudge,
moveJudge,
readJudgeOne,
type JudgeOne,
type JudgeRow,
type MoveResult,
} from "./M02_MasterTemplete_Hopyo_JudgeApi";
import {
bare,
confirmSuggested,
confirmable,
editLine,
lineOf,
linePicks,
missing,
moveBody,
putLine,
samePick,
togglePick,
type DraftPick,
} from "./logic/M02_MasterTemplete_Hopyo_Picks";
import { unitView } from "./logic/M02_MasterTemplete_Hopyo_Unit";
export { createJudgeState, type JudgeState };
const VERDICT: Record<string, BadgeTone> = {
"제안 있음": "success",
"2차 필요": "info",
"후보 여럿": "warning",
"후보 없음": "danger",
};
const show = (v: unknown): string =>
v === null || v === undefined ? "" : typeof v === "number" ? formatNumber(v) : String(v);
const bar = (kids: (HTMLElement | string)[]): HTMLElement =>
el("div", { className: "ui-toolbar m02-judge__wrap", children: kids });
export type SheetRole = "일위대가표" | "단가산출상세";
export interface JudgeRowsOptions {
host: HTMLElement;
/** 파일 층 */
file: HopyoScope;
key: string;
/** 만들 층 */
target: HopyoScope;
state: JudgeState;
no: number;
/** 엑셀 보기 그 행(일위대가표 · 단가산출 상세) */
onSheet?: (행: number, role: SheetRole) => void;
onClose?: () => void;
readOnly?: boolean;
}
const COLS: Column[] = [
{ label: L("M02_JudgeColRow"), width: "52px" },
{ label: L("M02_JudgeColName") },
{ label: L("M02_JudgeColVerdict"), width: "84px" },
{ label: L("M02_JudgeColKind") },
{ label: L("M02_JudgeColPick") },
{ label: L("M02_JudgeColQty"), align: "end" },
{ label: L("M02_JudgeColOk"), width: "72px" },
];
/** 2차 줄 「단가산출 통째」 고른 것 — 단산N(같은 옮기기 블록 먼저 SK) */
const wholeSk = (row: JudgeRow): DraftPick => ({
갈래: "단가산출",
키: `단산${row.번}`,
이름: row.이름 ?? `단산${row.번}`,
규격: row.규격 ?? "",
단위: row.단위 ?? "",
입력값: {},
출처: {},
몫: null,
});
/** 시트 아래 줄 판 — 호표 하나 */
export function createJudgeRows(opt: JudgeRowsOptions): { root: HTMLElement; destroy: () => void } {
const root = el("section", { className: "m02-judge", attrs: { "data-judge-rows": String(opt.no) } }); // prettier-ignore
root.append(createText(L("M02_JudgeLoading"), "hint"));
opt.host.replaceChildren(root);
let one: JudgeOne | null = null;
const open = new Set<number>();
const bodies = new Map<number, ReturnType<typeof solvedBody>>();
let alive = true;
const no = opt.no;
const picksOf = (row: JudgeRow): DraftPick[] => linePicks(opt.state.get(), no, row);
const edit = (row: JudgeRow, list: DraftPick[]): void =>
opt.state.set(editLine(opt.state.get(), no, row, list));
function chips(row: JudgeRow, picks: DraftPick[]): HTMLElement {
const kids: HTMLElement[] = [];
if (row.판정 === "2차 필요" && row.번 !== undefined) {
const solve = createToggle({ kind: "chip", label: L("M02_JudgeSolve"), checked: open.has(row.차례), onChange: (on) => { if (on) open.add(row.차례); else open.delete(row.차례); draw(); } }); // prettier-ignore
solve.root.dataset.judgeSolve = String(row.번);
const sk = wholeSk(row);
const whole = createToggle({ kind: "chip", label: `${L("M02_JudgeWholeSk")} ${sk.키}`, checked: picks.some((p) => samePick(p, sk)), disabled: opt.readOnly, onChange: () => edit(row, togglePick(picks, sk)) }); // prettier-ignore
whole.root.dataset.judgeWhole = String(sk.키);
kids.push(solve.root, whole.root);
for (const p of picks.filter((x) => x.갈래 === "기초")) {
const b = createBadge(`${p.키}`, missing(p).length ? "danger" : "accent");
b.dataset.judgePicked = String(p.키);
kids.push(b);
}
} else {
const seen = [...row.후보];
for (const p of picks) if (!seen.some((x) => samePick(x, p))) seen.push(p);
for (const c of seen) {
const on = picks.some((p) => samePick(p, c));
const label = `${c.갈래} ${c.이름}${c.규격 ? ` ${c.규격}` : ""}`;
const chip = createToggle({ kind: "chip", label, checked: on, disabled: opt.readOnly, onChange: () => edit(row, togglePick(picks, { ...c })) }); // prettier-ignore
chip.root.dataset.judgeCand = `${c.갈래}:${c.키 ?? c.이름}`;
chip.root.title = c.키 ?? "";
kids.push(chip.root);
}
if (!seen.length) kids.push(createText(L("M02_JudgeNoCand"), "warning"));
}
return bar(kids);
}
function qtyCell(row: JudgeRow, picks: DraftPick[]): HTMLElement {
const line = lineOf(opt.state.get(), no, row.차례);
const 배 = line?.배 ?? row.수량.배;
const view = unitView({ 배, 환산: { 배: row.수량.배, 갈래: row.수량.갈래 } }, row.단위, picks[0]?.단위); // prettier-ignore
const text = `${show(row.수량.엑셀)}${배 !== null && 배 !== 1 ? ` × ${show(배)}` : ""}`;
if (view.mode !== "input" || opt.readOnly) return bar([text]);
const f = createInputField({ compact: true, size: 24, value: show(배), unit: view.to, placeholder: L("M02_JudgeUnitHint").replace("{from}", view.from) }); // prettier-ignore
f.input.dataset.judgeTimes = String(row.차례);
f.input.addEventListener("change", () => {
const n = Number(f.input.value.replace(/,/g, ""));
const next = f.input.value.trim() === "" || !Number.isFinite(n) ? null : n;
opt.state.set(putLine(opt.state.get(), no, row.차례, { 고른: picks, 확인: false, 배: next }));
});
if (배 === null) f.setError(L("M02_JudgeNeed"));
return bar([text, L("M02_JudgeUnitHint").replace("{from}", view.from), f.root]);
}
function okCell(row: JudgeRow, picks: DraftPick[]): HTMLElement {
const line = lineOf(opt.state.get(), no, row.차례);
const ok = createToggle({
kind: "chip",
label: line?.확인 ? L("M02_JudgeConfirmed") : L("M02_JudgeConfirm"),
checked: !!line?.확인,
disabled: opt.readOnly,
onChange: (on) => {
if (on && !confirmable(picks)) {
ok.set(false);
const why = picks.length ? picks.flatMap(missing).join(" · ") : L("M02_JudgeNoCand");
showToast(`${L("M02_JudgeBlocked")} — ${why}`, "warning");
return;
}
opt.state.set(putLine(opt.state.get(), no, row.차례, { ...line, 고른: picks, 확인: on }));
},
});
ok.root.dataset.judgeOk = String(row.차례);
return ok.root;
}
function rowCells(row: JudgeRow): Cell[] {
const picks = picksOf(row);
const jump =
row.행 !== null && opt.onSheet
? createButton({ label: String(row.행), size: 24, variant: "link", onClick: () => opt.onSheet!(row.행!, "일위대가표") }) // prettier-ignore
: show(row.행);
if (jump instanceof HTMLElement) jump.dataset.judgeRow = String(row.행);
const v = createBadge(row.판정, VERDICT[row.판정] ?? "neutral");
v.dataset.judgeVerdict = row.판정;
const kinds = row.갈래.map((k) => {
const b = createBadge(k, "neutral");
b.dataset.judgeKind = k;
return b;
});
const name = [row.이름, row.규격, row.단위 ? `(${row.단위})` : ""].filter(Boolean).join(" ");
return [
jump,
name,
v,
bar([...kinds, row.근거]),
chips(row, picks),
qtyCell(row, picks),
okCell(row, picks),
];
}
/** 블록 풀이 몸이 쥐는 고른 것 = 단가산출 통째 칩 빼고(기초 · 덧 줄) */
const isSk = (p: DraftPick): boolean => p.갈래 === "단가산출";
const inner = (row: JudgeRow): DraftPick[] => picksOf(row).filter((p) => !isSk(p));
function solved(row: JudgeRow): HTMLElement {
let body = bodies.get(row.차례);
if (!body) {
body = solvedBody({
solved: row.풀이!,
picks: inner(row),
readOnly: opt.readOnly,
excel: row.단가,
onSheet: opt.onSheet ? (r) => opt.onSheet!(r, "단가산출상세") : undefined,
onPicks: (list) => edit(row, [...picksOf(row).filter(isSk), ...list]),
onCheck: (list, signal) =>
checkJudge(opt.file, opt.key, { 호표: no, 차례: row.차례, 고른: list.map((p) => bare(p)) }, signal), // prettier-ignore
});
bodies.set(row.차례, body);
} else body.set(inner(row));
return body.root;
}
function draw(): void {
if (!one) return;
const all = createButton({
label: L("M02_JudgeConfirmAll"),
size: 24,
variant: "ghost",
disabled: opt.readOnly,
onClick: () => opt.state.set(confirmSuggested(opt.state.get(), no, one!.줄)),
});
all.dataset.judgeAll = String(no);
const actions: HTMLElement[] = [all];
if (opt.onClose) actions.push(createButton({ label: L("Common_Btn_Close"), size: 24, variant: "ghost", onClick: opt.onClose })); // prettier-ignore
const title = `${L("M02_JudgeHopyo").replace("{n}", String(no))} ${one.이름}`;
const sub = [one.규격, one.단위, one.만든 ? `${L("M02_JudgeMade")} ${one.만든}` : ""].filter(Boolean).join(" · "); // prettier-ignore
const { root: table, tbody } = tableShell(COLS, { density: "data" });
for (const row of one.줄) {
const tr = tableRow(COLS, rowCells(row));
tr.dataset.judgeLine = String(row.차례);
tbody.append(tr);
if (row.풀이 && open.has(row.차례))
tbody.append(tableRow(COLS, [{ content: solved(row), colSpan: COLS.length }]));
}
root.replaceChildren(createPanelHead({ title, sub, actions, divider: true }).root, table);
}
const off = opt.state.on(draw);
void readJudgeOne(opt.file, opt.key, no, opt.target)
.then((got) => {
if (!alive) return;
one = got;
draw();
})
.catch((e: Error) => {
if (alive) root.replaceChildren(createText(e.message, "error"));
});
return {
root,
destroy(): void {
alive = false;
off();
root.remove();
},
};
}
export interface JudgeMoveOptions {
file: HopyoScope;
key: string;
target: HopyoScope;
state: JudgeState;
readOnly?: boolean;
onMoved?: (r: MoveResult) => void;
}
/** 오른쪽 판 아래 [옮기기 (n)] — 확인한 줄 · 블록만 · 서버가 정본으로 다시 셈 · 옮긴 것은 캐시에서 걷음 */
export function createJudgeMove(opt: JudgeMoveOptions): { root: HTMLElement; destroy: () => void } {
const btn = createButton({ label: L("M02_JudgeMove"), size: 28, variant: "filled", onClick: () => void go() }); // prettier-ignore
btn.dataset.judgeMove = "";
const root = bar([btn]);
const count = (): void => {
const n = moveBody(opt.state.get(), opt.file).수;
btn.textContent = `${L("M02_JudgeMove")} (${n})`;
btn.disabled = !!opt.readOnly || n === 0;
};
async function go(): Promise<void> {
const { body, 막힘, 수 } = moveBody(opt.state.get(), opt.file);
if (!수) {
showToast(L("M02_JudgeMoveNone"), "info");
return;
}
btn.disabled = true;
try {
const r = await moveJudge(opt.target, opt.key, body);
const stuck = new Set(r.막힘.map((x) => `${x.호표 ?? "b" + x.블록}:${x.차례 ?? ""}`));
let d = opt.state.get();
for (const [h, lines] of Object.entries(body.호표))
for (const at of Object.keys(lines))
if (!stuck.has(`${h}:${at}`)) {
const { [at]: _gone, ...rest } = d.호표[h] ?? {};
d = { ...d, 호표: { ...d.호표, [h]: rest } };
}
for (const b of Object.keys(body.블록))
if (!stuck.has(`b${b}:`)) {
const { [b]: _gone, ...rest } = d.블록;
d = { ...d, 블록: rest };
}
opt.state.set(d);
const msg = L("M02_JudgeMoved")
.replace("{a}", String(r.만듦.length))
.replace("{b}", String(r.단가산출.length))
.replace("{c}", String(r.막힘.length + 막힘.length));
showToast(msg, r.막힘.length + 막힘.length ? "warning" : "success");
opt.onMoved?.(r);
} catch (e) {
showToast((e as Error).message, "error");
} finally {
count();
}
}
const off = opt.state.on(count);
count();
return {
root,
destroy(): void {
off();
root.remove();
},
};
}
@@ -0,0 +1,222 @@
/* =============================================================================
* M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_Picks.ts
* 엑셀 불러오기 판정 화면 고름 캐시(PLAN 785-6 · 요구 641 · 642 · 설계 642-A 2-6) — 순수 셈.
* · 캐시 꼴 `{호표 {번: {차례: 고름}}, 블록 {번: 고름}}` · 고름 = `{고른 [고른 것 + 필요?], 확인, 배?}` —
* sessionStorage 에 넣고 빼는 것은 화면(`Hopyo_Rows`) · 여기는 꼴 · 읽기 · 바꾸기만
* · 제안 = 미리 체크로만(캐시에 없으면 서버 제안) · 확인 = 사용자 [확인] · [호표 제안 모두 확인] 누름만(자동 없음)
* · 기초 고른 것 `필요` = 엔진이 쓰는데 못 읽은 칸(입력 필요) + 엇갈림 칸 — 값이 비면 그 줄 확인 · 옮김 막음
* · 옮기기 몸 = 확인한 줄 · 블록만 · `필요` 걷음 · 줄 `배`(단위 환산 사용자 값) 얹음 · 금액 · 품 칸 안 보냄(서버가 다시 셈)
* · 칠 = 확인함 success · 품 다름 warning · 고를 것 info · 후보 없음 danger
* 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장).
* ========================================================================== */
import type {
BaseCand,
JudgeRow,
MoveBody,
MoveBlocked,
Pick,
Section,
Solved,
} from "../M02_MasterTemplete_Hopyo_JudgeApi";
import type { Tone } from "./M02_MasterTemplete_Hopyo_SheetMap";
/** 캐시 안 고른 것 — `필요` = 비면 막는 칸(화면만 · 몸에선 걷음) */
export type DraftPick = Pick & { 필요?: string[] };
export interface LineDraft {
고른: DraftPick[];
확인: boolean;
/** 단위 환산 사용자 값(엑셀 1 단위 = 우리 배) */
배?: number | null;
}
export interface JudgeDraft {
호표: Record<string, Record<string, LineDraft>>;
블록: Record<string, LineDraft>;
}
export const draftKey = (layer: string, owner: string | null, key: string): string =>
`m02-hopyo-judge:${layer}:${owner ?? ""}:${key}`;
export const emptyDraft = (): JudgeDraft => ({ 호표: {}, 블록: {} });
export function parseDraft(text: string | null): JudgeDraft {
try {
const d = JSON.parse(text ?? "") as Partial<JudgeDraft>;
if (d && typeof d === "object" && d.호표 && d.블록) return { 호표: d.호표, 블록: d.블록 };
} catch {
/* 빈 · 깨진 캐시 = 빈 캐시 */
}
return emptyDraft();
}
const blank = (v: unknown): boolean => v === undefined || v === null || String(v).trim() === "";
/** 절 기초 후보 → 고른 것(엔진 입력값 · 출처 · 몫 · 필요 = 입력 필요 + 엇갈림) */
export function basePick(cand: BaseCand, sec: Section): DraftPick {
const b = cand.바인딩;
return {
갈래: "기초",
키: cand.키,
이름: cand.이름,
입력값: { ...b.입력값 },
출처: { ...b.출처 },
몫: sec.몫?.값 ?? null,
필요: [...new Set([...b.입력필요, ...Object.keys(b.엇갈림)])],
};
}
/** 블록 풀이 제안 — 절마다 고름 기초 하나 */
export function solvedPicks(s: Solved | null | undefined): DraftPick[] {
const out: DraftPick[] = [];
for (const sec of s?.절 ?? []) {
const c = sec.후보.find((x) => x.키 === sec.고름);
if (c) out.push(basePick(c, sec));
}
return out;
}
/** 줄 제안(캐시 없을 때) — 제안 있음 · 후보 없음 = 첫 후보 · 2차 = 블록 풀이 · 후보 여럿 = 빈 */
export function rowDefaults(row: JudgeRow): DraftPick[] {
if (row.판정 === "2차 필요") return solvedPicks(row.풀이);
if (row.판정 === "후보 여럿") return [];
return row.후보.slice(0, 1).map((p) => ({ ...p }));
}
export const lineOf = (d: JudgeDraft, no: number, at: number): LineDraft | undefined =>
d.호표[String(no)]?.[String(at)];
/** 지금 고른 것 — 캐시 › 제안 */
export const linePicks = (d: JudgeDraft, no: number, row: JudgeRow): DraftPick[] =>
lineOf(d, no, row.차례)?.고른 ?? rowDefaults(row);
export const blockPicks = (d: JudgeDraft, s: Solved): DraftPick[] =>
d.블록[String(s.번)]?.고른 ?? solvedPicks(s);
export const samePick = (a: Pick, b: Pick): boolean =>
a.갈래 === b.갈래 && a.키 === b.키 && a.이름 === b.이름 && (a.규격 ?? "") === (b.규격 ?? "");
/** 복수 고름 — 있으면 뺌 · 없으면 더함 */
export function togglePick(list: DraftPick[], p: DraftPick): DraftPick[] {
return list.some((x) => samePick(x, p)) ? list.filter((x) => !samePick(x, p)) : [...list, p];
}
/** 입력값 한 칸 — 출처 「사용자」(엇갈림에서 고르면 그 길) · 빈 글 = 칸 지움 */
export function setInput(p: DraftPick, name: string, value: unknown, 길 = "사용자"): DraftPick {
const 입력값 = { ...p.입력값 };
const 출처 = { ...p.출처 };
if (blank(value)) {
delete 입력값[name];
delete 출처[name];
} else {
const n = typeof value === "string" ? Number(value.replace(/,/g, "")) : NaN;
입력값[name] = typeof value === "string" && value.trim() !== "" && Number.isFinite(n) ? n : value; // prettier-ignore
출처[name] = { 길 };
}
return { ...p, 입력값, 출처 };
}
/** 아직 빈 막는 칸 */
export const missing = (p: DraftPick): string[] => (p.필요 ?? []).filter((k) => blank(p.입력값[k]));
/** 줄 막힘 — 고른 것 없음 · 빈 막는 칸 */
export function blockedOf(list: DraftPick[]): string[] {
return list.length ? [...new Set(list.flatMap(missing))] : ["고른 것 없음"];
}
export const confirmable = (list: DraftPick[]): boolean =>
list.length > 0 && list.every((p) => missing(p).length === 0);
/** 호표 줄 고름 넣기(새 캐시) */
export function putLine(d: JudgeDraft, no: number, at: number, line: LineDraft): JudgeDraft {
const h = { ...(d.호표[String(no)] ?? {}), [String(at)]: line };
return { ...d, 호표: { ...d.호표, [String(no)]: h } };
}
export function putBlock(d: JudgeDraft, no: number, line: LineDraft): JudgeDraft {
return { ...d, 블록: { ...d.블록, [String(no)]: line } };
}
/** 줄 하나 바꿈 — 고른 것이 바뀌면 확인 풀림 */
export function editLine(d: JudgeDraft, no: number, row: JudgeRow, 고른: DraftPick[]): JudgeDraft {
const was = lineOf(d, no, row.차례);
return putLine(d, no, row.차례, { ...was, 고른, 확인: false });
}
/** 제안이 다 풀린 줄 — 제안 있음(후보 하나) · 2차 기초로 풂 · 기초+덧(막는 칸 0) */
export function suggestReady(row: JudgeRow): boolean {
if (row.판정 === "제안 있음") return row.후보.length > 0;
if (row.판정 !== "2차 필요" || !row.풀이) return false;
const ok = row.풀이.판정 === "기초로 풂" || row.풀이.판정 === "기초+덧";
return ok && confirmable(solvedPicks(row.풀이));
}
/** [호표 제안 모두 확인] — 다 풀린 제안 줄만 확인 켬(사용자 누름 · 이미 손댄 줄은 그대로) */
export function confirmSuggested(d: JudgeDraft, no: number, rows: JudgeRow[]): JudgeDraft {
let out = d;
for (const row of rows) {
if (lineOf(out, no, row.차례) || !suggestReady(row)) continue;
out = putLine(out, no, row.차례, { 고른: rowDefaults(row), 확인: true });
}
return out;
}
const PAIR_BAD = new Set(["품 다름"]);
const pairBad = (s: Solved | null | undefined, keys: Set<string | null>): boolean =>
(s?.절 ?? []).some((sec) =>
sec.후보.some((c) => keys.has(c.키) && c.줄.some((x) => PAIR_BAD.has(x.판정))),
);
/** 줄 칠 — 확인 = success(품 다름 = warning) · 후보 없음 = danger · 그 밖 = info */
export function rowTone(row: JudgeRow, line: LineDraft | undefined): Tone {
if (line?.확인) {
const keys = new Set(line.고른.map((p) => p.키));
return pairBad(row.풀이, keys) ? "warning" : "success";
}
return row.판정 === "후보 없음" ? "danger" : "info";
}
/** 블록 칠 — 만든 SK · 확인 = success · 고를 것 = info · 줄 조합 = info · 후보 0 = danger */
export function blockTone(s: Solved, line: LineDraft | undefined, made: string | null): Tone {
if (line?.확인 || made) return "success";
return s.절.every((sec) => sec.후보.length === 0) && s.판정 !== "줄 조합" ? "danger" : "info";
}
/** 몸 꼴 — `필요` 걷음 · 배 얹음 */
export const bare = (p: DraftPick, 배?: number | null): Pick => {
const { 필요: _need, ...rest } = p;
return 배 === undefined || 배 === null ? rest : { ...rest, 배 };
};
/** 옮기기 몸 — 확인한 것만 · 막는 칸 남은 줄 = 막힘(몸에서 뺌) */
export function moveBody(
d: JudgeDraft,
file: { layer: string; owner: string | null },
): { body: MoveBody; 막힘: MoveBlocked[]; 수: number } {
const body: MoveBody = { 층: file.layer, 호표: {}, 블록: {} };
if (file.owner) body.주인 = file.owner;
const 막힘: MoveBlocked[] = [];
let 수 = 0;
for (const [no, lines] of Object.entries(d.호표)) {
for (const [at, line] of Object.entries(lines)) {
if (!line.확인) continue;
const 칸 = line.고른.length ? line.고른.flatMap(missing) : ["고른 것 없음"];
if (칸.length) {
막힘.push({ 호표: Number(no), 차례: Number(at), 까닭: "입력 필요", 칸 });
continue;
}
(body.호표[no] ??= {})[at] = line.고른.map((p) => bare(p, line.배));
수 += 1;
}
}
for (const [no, line] of Object.entries(d.블록)) {
if (!line.확인) continue;
const 칸 = blockedOf(line.고른);
if (line.고른.length && 칸.length === 0) {
body.블록[no] = line.고른.map((p) => bare(p));
수 += 1;
} else 막힘.push({ 블록: Number(no), 까닭: "입력 필요", 칸 });
}
return { body, 막힘, 수 };
}
+23
View File
@@ -0,0 +1,23 @@
/* 642-6(PLAN 785-6) — `logic/M02_MasterTemplete_Hopyo_Picks.ts`(판정 화면 고름 캐시 순수 셈)를 트랜스파일해 Node 로 돌림.
* stdin = {steps: [{fn, args}]} — args 안 `{"$": i}` = i 번째 걸음 결과 · stdout = 걸음 결과 배열 JSON. */
const fs = require("fs");
const path = require("path");
const { makeLoader } = require("./helper_ts_stub_loader.cjs");
const ROOT = path.join(__dirname, "..", "..");
const { loadTs } = makeLoader({ root: ROOT });
const picks = loadTs(
path.join(ROOT, "M02_MasterTemplete", "logic", "M02_MasterTemplete_Hopyo_Picks.ts"),
);
const input = JSON.parse(fs.readFileSync(0, "utf8"));
const out = [];
const ref = (v) =>
v && typeof v === "object"
? "$" in v && Object.keys(v).length === 1
? out[v.$]
: Array.isArray(v)
? v.map(ref)
: Object.fromEntries(Object.entries(v).map(([k, x]) => [k, ref(x)]))
: v;
for (const s of input.steps) out.push(picks[s.fn](...ref(s.args)) ?? null);
process.stdout.write(JSON.stringify(out));
+226
View File
@@ -0,0 +1,226 @@
# -*- coding: utf-8 -*-
"""642-6(PLAN 785-6) 판정 화면 지킴 — 줄 판 · 블록 판 · 고름 캐시(설계 642-A 5-3 다섯째 줄).
- 부품 상자만(날 `<button>` · `<input>` 0 · 색은 공통 값) · 700 줄.
- 고름 캐시 순수 셈(`logic/Hopyo_Picks` · cjs 도우미) — 북부청 실제 줄 판(785-3 `Row.rows`) +
블록 풀이(785-2 `Solve.block`)를 그대로 넣어 봄: 제안 = 미리 체크만(빈 캐시 = 옮길 것 0) ·
[호표 제안 모두 확인] = 다 풀린 제안만 · 예시 1(단산10 GC001140 입력 필요 0) · 예시 2(단산47 기초 둘 ·
구분 엇갈림 → 토목) · 예시 3(단산24 GC000211 높이감률 · 구멍땜 입력 필요 → 0) · 옮기기 몸 = 확인한 것만 ·
막는 칸 남은 줄 막음 · `필요` 걷음 · 배 얹음.
읽기만(엑셀 · M01) · storage 안 씀.
"""
import json
import re
import subprocess
from pathlib import Path
import pytest
from M02_MasterTemplete import M02_Hopyo_Bind as hb
from M02_MasterTemplete import M02_Hopyo_Clause as hc
from M02_MasterTemplete import M02_Hopyo_Excel as hx
from M02_MasterTemplete import M02_Hopyo_Row as hr
from M02_MasterTemplete import M02_Hopyo_Solve as hs
ROOT = Path(__file__).resolve().parents[2]
HERE = Path(__file__).resolve().parent
M02 = ROOT / "M02_MasterTemplete"
NORTH = (
ROOT
/ "resources/knowledge/original/실무문서/2026_실무일위대가 모음"
/ "2026년_북부지방산림청_임도사업_설계내역서(공통단가).xls"
)
NEW = [
M02 / "M02_MasterTemplete_Hopyo_Rows.ts",
M02 / "M02_MasterTemplete_Hopyo_Block.ts",
M02 / "M02_MasterTemplete_Hopyo_JudgeApi.ts",
M02 / "logic/M02_MasterTemplete_Hopyo_Picks.ts",
]
FILE = {"layer": "personal", "owner": None}
def _run(steps: list[dict]) -> list:
proc = subprocess.run(
["node", str(HERE / "helper_642_6_picks.cjs")],
input=json.dumps({"steps": steps}, ensure_ascii=False),
capture_output=True,
text=True,
encoding="utf-8",
check=False,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
@pytest.fixture(scope="module")
def judged():
"""호표 1 · 2 줄 판 + 단산 줄 풀이(예시 셋) — 785-5 `hopyo-judge/{키}/{번}` 꼴."""
book = hx.read_book(NORTH.name, NORTH.read_bytes())
master = hb.m01.cm.master()
rows = hr.rows(book, None)
blocks = {b["번"]: b for b in book["단산"]}
out = {}
for no in (1, 2):
hopyo = next(h for h in book["호표"] if h["번"] == no)
for row in rows[no]:
if row.get("번") not in (10, 24, 47):
continue
blk = blocks[row["번"]]
cands = [{"키": c["키"], "절": c.get("절")} for c in hc.basis(blk)["후보"]]
line = next(x for x in hopyo["줄"] if (x.get("참조") or {}).get("번") == row["번"])
same = [x for x in hopyo["줄"] if x is not line]
row["풀이"] = hs.block(master, blk, hs.parts(blk, cands), {"줄": line, "같은줄": same})
out[no] = rows[no]
return out
def _row(judged, no, sk):
return next(r for r in judged[no] if r.get("번") == sk)
def test_부품상자만_700줄():
for f in NEW:
text = f.read_text(encoding="utf-8")
assert len(text.splitlines()) <= 700, f.name
assert not re.search(r"""createElement\(\s*["'](button|input)["']""", text), f.name
assert not re.search(r"""\bel\(\s*["'](button|input|select)["']""", text), f.name
assert "<button" not in text and "<input" not in text, f.name
css = (M02 / "M02_MasterTemplete_Hopyo_Judge.css").read_text(encoding="utf-8")
assert not re.search(r"#[0-9a-fA-F]{3,8}\b|rgba?\(", css)
picks = NEW[3].read_text(encoding="utf-8")
assert not re.search(r"sessionStorage|fetch\(|document\.", picks.split("*/", 1)[1])
def test_제안은_미리체크만_자동확인없음(judged):
rows = judged[1]
got = _run(
[
{"fn": "emptyDraft", "args": []},
{"fn": "moveBody", "args": [{"$": 0}, FILE]},
*[{"fn": "rowDefaults", "args": [r]} for r in rows],
]
)
assert got[1]["수"] == 0 and got[1]["body"]["호표"] == {}
defaults = dict(zip([r["차례"] for r in rows], got[2:]))
first = rows[0]
assert first["판정"] == "제안 있음" and defaults[0][0]["키"] == first["후보"][0]["키"]
assert defaults[4][0]["갈래"] == "고정" # 후보 없음 = 고정 금액 제안
assert [p["키"] for p in defaults[5]] == ["GC000206", "GC000207"] # 예시 2 절마다 기초
def test_호표_제안_모두_확인은_다풀린_제안만(judged):
rows = judged[1]
got = _run(
[
{"fn": "emptyDraft", "args": []},
{"fn": "confirmSuggested", "args": [{"$": 0}, 1, rows]},
{"fn": "moveBody", "args": [{"$": 1}, FILE]},
]
)
on = {int(k) for k, v in got[1]["호표"]["1"].items() if v["확인"]}
assert on == {
0
} # 레미콘 별도계상 — 단산24 · 47 = 입력 필요 · 엇갈림 · 46 · 33 = 풀이 없음 · 면목 = 후보 없음
assert got[2]["수"] == 1 and list(got[2]["body"]["호표"]["1"]) == ["0"]
def test_예시1_단산10_입력필요0(judged):
row = _row(judged, 2, 10)
got = _run(
[
{"fn": "rowDefaults", "args": [row]},
{"fn": "suggestReady", "args": [row]},
{"fn": "confirmable", "args": [{"$": 0}]},
]
)
(p,) = got[0]
assert p["키"] == "GC001140" and p["필요"] == []
assert p["입력값"] == {
"운반거리": 6,
"경사각도": 35,
"운반회수": 42,
"운반무게": 575,
"회당운반량": 25,
}
assert {k: v["길"] for k, v in p["출처"].items()}["운반무게"] == "B4"
assert got[1] is True and got[2] is True
money = row["풀이"]["절"][0]["후보"][0]["금액"]["계"]
assert int(money) == 94227
def test_예시2_단산47_기초둘_구분엇갈림(judged):
row = _row(judged, 1, 47)
got = _run(
[
{"fn": "rowDefaults", "args": [row]},
{"fn": "blockedOf", "args": [{"$": 0}]},
]
)
assert [p["키"] for p in got[0]] == ["GC000206", "GC000207"]
assert got[1] == ["구분"]
fixed = _run([{"fn": "setInput", "args": [got[0][1], "구분", "토목", "B5"]}])[0]
assert fixed["입력값"]["구분"] == "토목" and fixed["출처"]["구분"] == {"길": "B5"}
assert _run([{"fn": "confirmable", "args": [[got[0][0], fixed]]}])[0] is True
secs = [s["제목"] for s in row["풀이"]["절"]]
assert len(secs) == 2 # 맡은 절 둘
def test_예시3_단산24_입력필요_0넣으면_풀림(judged):
row = _row(judged, 1, 24)
(p,) = _run([{"fn": "rowDefaults", "args": [row]}])[0]
assert p["키"] == "GC000211" and set(p["필요"]) == {"높이감률", "구멍땜"}
got = _run(
[
{"fn": "missing", "args": [p]},
{"fn": "setInput", "args": [p, "높이감률", "0"]},
{"fn": "setInput", "args": [{"$": 1}, "구멍땜", "0"]},
{"fn": "missing", "args": [{"$": 2}]},
]
)
assert set(got[0]) == {"높이감률", "구멍땜"} and got[3] == []
assert got[2]["입력값"]["높이감률"] == 0 and got[2]["출처"]["구멍땜"] == {"길": "사용자"}
assert got[2]["입력값"]["유형"] == "보통" and got[2]["입력값"]["사용횟수"] == "4회"
def test_옮기기몸_확인한것만_막힘_필요걷음_배(judged):
r24, r47 = _row(judged, 1, 24), _row(judged, 1, 47)
steps = [
{"fn": "emptyDraft", "args": []},
{"fn": "rowDefaults", "args": [r24]},
# 단산24 = 막는 칸 남은 채 확인 → 막힘
{"fn": "putLine", "args": [{"$": 0}, 1, r24["차례"], {"고른": {"$": 1}, "확인": True}]},
{"fn": "rowDefaults", "args": [r47]},
{"fn": "setInput", "args": [{"$": 3}, "구분", "토목", "B5"]},
# 단산47 은 확인 안 함 → 몸에 없음
{"fn": "putLine", "args": [{"$": 2}, 1, r47["차례"], {"고른": [], "확인": False}]},
{"fn": "rowDefaults", "args": [judged[1][0]]},
{"fn": "putLine", "args": [{"$": 5}, 1, 0, {"고른": {"$": 6}, "확인": True, "배": 1.5}]},
{"fn": "moveBody", "args": [{"$": 7}, {"layer": "personal", "owner": "u1"}]},
]
body = _run(steps)[-1]
assert body["수"] == 1
assert body["body"]["층"] == "personal" and body["body"]["주인"] == "u1"
(moved,) = body["body"]["호표"]["1"]["0"]
assert moved["배"] == 1.5 and "필요" not in moved
assert body["막힘"] == [
{"호표": 1, "차례": r24["차례"], "까닭": "입력 필요", "칸": ["높이감률", "구멍땜"]}
]
def test_고름바꾸면_확인풀림_칠(judged):
row = judged[1][0]
got = _run(
[
{"fn": "emptyDraft", "args": []},
{"fn": "rowDefaults", "args": [row]},
{"fn": "putLine", "args": [{"$": 0}, 1, 0, {"고른": {"$": 1}, "확인": True}]},
{"fn": "rowTone", "args": [row, {"고른": {"$": 1}, "확인": True}]},
{"fn": "editLine", "args": [{"$": 2}, 1, row, []]},
{"fn": "rowTone", "args": [judged[1][4], None]},
{"fn": "rowTone", "args": [judged[1][1], None]},
]
)
assert got[3] == "success"
assert got[4]["호표"]["1"]["0"]["확인"] is False
assert got[5] == "danger" and got[6] == "info"
+2
View File
@@ -33,6 +33,7 @@ import { ui_locales_e04 } from "./ui_template_locale_e04";
import { ui_locales_b5 } from "./ui_template_locale_b5";
import { ui_locales_m1 } from "./ui_template_locale_m1";
import { ui_locales_m2 } from "./ui_template_locale_m2";
import { ui_locales_m2_judge } from "./ui_template_locale_m2_judge";
import { ui_locales_de } from "./ui_template_locale_de";
import { ui_locales_e } from "./ui_template_locale_e";
import { ui_locales_e05 } from "./ui_template_locale_e05";
@@ -77,6 +78,7 @@ export const ui_locales = {
...ui_locales_b5,
...ui_locales_m1,
...ui_locales_m2,
...ui_locales_m2_judge,
...ui_locales_de,
...ui_locales_e,
...ui_locales_e05,
@@ -0,0 +1,57 @@
/* =============================================================================
* ui_template_locale_m2_judge.ts
* M02 엑셀 불러오기 판정 화면(줄 판 · 블록 판 · PLAN 785-6) 사전 — 신규 문구는 최하단에 추가.
* ========================================================================== */
export const ui_locales_m2_judge = {
M02_JudgeLoading: ["판정 읽는 중", "Loading verdicts"],
M02_JudgeHopyo: ["제{n}호표", "Item {n}"],
M02_JudgeBlock: ["단산{n}", "Calc {n}"],
M02_JudgeColRow: ["행", "Row"],
M02_JudgeColName: ["엑셀 품명 · 규격", "Excel name · spec"],
M02_JudgeColVerdict: ["1차 판정", "Verdict"],
M02_JudgeColKind: ["갈래 · 근거", "Kind · basis"],
M02_JudgeColPick: ["후보(여럿 고름)", "Candidates (multi)"],
M02_JudgeColQty: ["수량", "Qty"],
M02_JudgeColOk: ["확인", "Confirm"],
M02_JudgeConfirm: ["확인", "Confirm"],
M02_JudgeConfirmed: ["확인함", "Confirmed"],
M02_JudgeConfirmAll: ["호표 제안 모두 확인", "Confirm all suggestions"],
M02_JudgeCheck: ["검산", "Check"],
M02_JudgeSheet: ["보기", "View"],
M02_JudgeSolve: ["블록 풀이", "Block solve"],
M02_JudgeWholeSk: ["단가산출 통째", "Whole unit calc"],
M02_JudgeSection: ["맡은 절", "Section"],
M02_JudgeNoBase: ["기초 후보 없음", "No base candidate"],
M02_JudgeColInput: ["입력 칸", "Input"],
M02_JudgeColValue: ["값", "Value"],
M02_JudgeColSource: ["출처", "Source"],
M02_JudgeNeed: ["입력 필요", "Input needed"],
M02_JudgeConflict: ["엇갈림", "Conflict"],
M02_JudgeDefault: ["기본", "Default"],
M02_JudgeUser: ["사용자", "User"],
M02_JudgeUnused: ["안 씀", "Unused"],
M02_JudgeColGroup: ["묶음", "Group"],
M02_JudgeColExcel: ["엑셀", "Excel"],
M02_JudgeColOurs: ["우리", "Ours"],
M02_JudgeColPair: ["대조", "Pair"],
M02_JudgeOursMoney: ["우리 금액", "Our amount"],
M02_JudgeExcelMoney: ["엑셀 금액", "Excel amount"],
M02_JudgeExtra: ["덧 줄(엑셀만)", "Extra lines (Excel only)"],
M02_JudgeNoCand: ["후보 없음", "No candidate"],
M02_JudgeBlocked: ["입력 필요 칸이 남음", "Inputs still needed"],
M02_JudgeMove: ["옮기기", "Move"],
M02_JudgeMoveNone: ["확인한 것 없음", "Nothing confirmed"],
M02_JudgeMoved: [
"옮김 — 일위대가 {a} · 단가산출 {b} · 막힘 {c}",
"Moved — items {a} · unit calcs {b} · blocked {c}",
],
M02_JudgeCalls: ["부름", "Called by"],
M02_JudgeMade: ["만든", "Made"],
M02_JudgeUnitHint: ["엑셀 1 {from} = 우리", "Excel 1 {from} = ours"],
M02_JudgePickN: ["고를 것 {n}", "To pick {n}"],
M02_JudgeAmountNote: [
"금액은 참고 — 판정은 품",
"Amounts are reference — verdict is by quantity",
],
} as const;