From 0117dbdf2e16e82abc3720f012586d9550478f6a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 4 Nov 2026 22:41:42 +0900 Subject: [PATCH] =?UTF-8?q?feat(785-6):=20=ED=8C=90=EC=A0=95=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EC=83=88=20=ED=8C=8C=EC=9D=BC=20=E2=80=94=20?= =?UTF-8?q?=EC=A4=84=20=ED=8C=90=20=C2=B7=20=EB=B8=94=EB=A1=9D=20=ED=8C=90?= =?UTF-8?q?=20=C2=B7=20=EA=B3=A0=EB=A6=84=20=EC=BA=90=EC=8B=9C=20=C2=B7=20?= =?UTF-8?q?=EC=83=88=20=EA=B8=B8=20=ED=83=80=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../M02_MasterTemplete_Hopyo_Block.ts | 432 ++++++++++++++++++ .../M02_MasterTemplete_Hopyo_Judge.css | 33 ++ .../M02_MasterTemplete_Hopyo_JudgeApi.ts | 262 +++++++++++ .../M02_MasterTemplete_Hopyo_Rows.ts | 340 ++++++++++++++ .../logic/M02_MasterTemplete_Hopyo_Picks.ts | 222 +++++++++ resources/tester/helper_642_6_picks.cjs | 23 + resources/tester/test_642_6_screen.py | 226 +++++++++ ui_template/ui_template_locale.ts | 2 + ui_template/ui_template_locale_m2_judge.ts | 57 +++ 9 files changed, 1597 insertions(+) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Hopyo_Block.ts create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Hopyo_Judge.css create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Hopyo_JudgeApi.ts create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Hopyo_Rows.ts create mode 100644 M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_Picks.ts create mode 100644 resources/tester/helper_642_6_picks.cjs create mode 100644 resources/tester/test_642_6_screen.py create mode 100644 ui_template/ui_template_locale_m2_judge.ts diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Block.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Block.ts new file mode 100644 index 000000000..7a392e412 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Block.ts @@ -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; + +const TONE: Record = { + "기초로 풂": "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 | null | undefined): string { + if (!m) return "—"; + if (m.계 === null && "까닭" in m) return String(m.까닭); + const x = m as Partial; + 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; + /** 엑셀 보기 단가산출 상세 그 행 */ + onSheet?: (행: number) => void; + excel?: Partial | 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[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 = {}; + 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 { + 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 | 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(); + }, + }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Judge.css b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Judge.css new file mode 100644 index 000000000..8c40d2f03 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Judge.css @@ -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; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_JudgeApi.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_JudgeApi.ts new file mode 100644 index 000000000..59c3712eb --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_JudgeApi.ts @@ -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 | number | null; + 입력값: Record; + 출처: Record; + 몫: number | null; + 비목?: string; + 기준?: unknown; + 별도계상?: boolean; + 층?: string; + 주인?: string | null; + 배?: number | null; +} + +export interface Binding { + 입력값: Record; + 출처: Record; + 엇갈림: Record; + 안씀: string[]; + 입력필요: string[]; + 비움: string[]; + 기본: Record; +} + +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>; + 판정: 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 | null; +} + +/** 호표 줄 판 한 줄(785-3 + 엑셀 글 · 단산 줄 풀이) */ +export interface JudgeRow { + 차례: number; + 행: number | null; + 갈래: PickKind[]; + 근거: string; + 번?: number; + 후보: Pick[]; + 판정: RowVerdict; + 수량: { 엑셀: number | null; 배: number | null; 갈래: string }; + 이름?: string; + 규격?: string; + 단위?: string; + 단가?: Partial | null; + 풀이?: Solved | null; +} + +export interface JudgeBrief { + 번: number; + 이름: string; + 규격: string; + 단위: string; + 줄수: number; + 판정: Partial>; + 만든: 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[]; + 줄?: PairLine[]; + 판정: string; + 금액: CandMoney; +} + +export interface CheckResult { + 고른: CheckOne[]; + 수량?: { 엑셀: number | null; 배: number | null; 몫: number | null }; +} + +export interface MoveBody { + 층: string; + 주인?: string | null; + 호표: Record>; + 블록: Record; +} + +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 => + call(at(scope, `/hopyo-judge/${enc(key)}${toQuery(to)}`)); + +export const readJudgeOne = ( + scope: HopyoScope, + key: string, + no: number, + to?: HopyoScope, +): Promise => call(at(scope, `/hopyo-judge/${enc(key)}/${no}${toQuery(to)}`)); + +export const readJudgeBlock = ( + scope: HopyoScope, + key: string, + no: number, + to?: HopyoScope, +): Promise => call(at(scope, `/hopyo-judge/${enc(key)}/block/${no}${toQuery(to)}`)); + +/** 검산(저장 없음) — `signal` = 옛 요청 끊기 */ +export const checkJudge = ( + scope: HopyoScope, + key: string, + body: CheckBody, + signal?: AbortSignal, +): Promise => + 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 => + call(at(target, `/hopyo/${enc(key)}/move`), { method: "POST", body: JSON.stringify(body) }); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Rows.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Rows.ts new file mode 100644 index 000000000..fabccaaf5 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Rows.ts @@ -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 = { + "제안 있음": "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(); + const bodies = new Map>(); + 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 { + 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(); + }, + }; +} diff --git a/M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_Picks.ts b/M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_Picks.ts new file mode 100644 index 000000000..35b07e402 --- /dev/null +++ b/M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_Picks.ts @@ -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>; + 블록: Record; +} + +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; + 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): 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, 막힘, 수 }; +} diff --git a/resources/tester/helper_642_6_picks.cjs b/resources/tester/helper_642_6_picks.cjs new file mode 100644 index 000000000..f42154ca5 --- /dev/null +++ b/resources/tester/helper_642_6_picks.cjs @@ -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)); diff --git a/resources/tester/test_642_6_screen.py b/resources/tester/test_642_6_screen.py new file mode 100644 index 000000000..206e30fd5 --- /dev/null +++ b/resources/tester/test_642_6_screen.py @@ -0,0 +1,226 @@ +# -*- coding: utf-8 -*- +"""642-6(PLAN 785-6) 판정 화면 지킴 — 줄 판 · 블록 판 · 고름 캐시(설계 642-A 5-3 다섯째 줄). + +- 부품 상자만(날 `