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:
@@ -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, 막힘, 수 };
|
||||
}
|
||||
@@ -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));
|
||||
@@ -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"
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user