/* ============================================================================= * M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_SkPick.ts * 마법사 단가산출 줄 고름(PLAN 778-5 D-5 · 요구 621 · 622 · 623 · 624) — 순수 셈. * · 재료 = 서버 `POST …/hopyo/{키}/sangeun` 답 `판정 [{번 … 줄 […]}]`(D-3 `M02_Hopyo_Sangeun.build`) * · 고름 캐시 `{고름 {번: {차례: 고름}}, 확인 [번]}` — 줄 고름이 서버 상태를 덮음(꼬리표 「내가 고름」) * · 블록 [확인] = 남은 고를 줄 0 일 때만 · 몸 = `{확인: [{번, 고름?}]}`(확인한 블록만 · 자동 확인 없음) * · 금액 · 판정 정본은 서버 — 여기는 캐시를 겹쳐 보이기 · 몸 짓기만 * · 780-2(요구 627) — 블록 `부름 [호표 번]`(빈 = 일위대가 밖 · 칸 없음 = 옛 서버 = 안) · 줄 0 = 「줄 없음」 확인 막음 · * 거르기(모두 · 안 · 밖 · 고를 줄 있음) * 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장). * ========================================================================== */ export type SkGroup = "L" | "X" | "M" | "R" | "S" | "E"; /** 줄 고름 — 노임 LB · 기계 EQ · 자재 MT · 율 기준 · 뺌 + 까닭 */ export type SkPick = | { 노임: string } | { 기계: string } | { 자재: string; 별도계상?: boolean } | { 기준: SkBase; 비목?: string } | { 뺌: string }; /** 율 기준 — 묶음 · 비목 · 줄 목록(그 엑셀 행 줄) */ export type SkBase = string | { 행: number[] }; /** 서버 후보 — 노임 · 기계 = `키` · 자재 = `자재` · 율 = `기준` */ export interface SkCand { 키?: string; 자재?: string; 기준?: SkBase; 비목?: string; 갈래?: string; 이름?: string | null; 규격?: string; 단위?: string; 구분?: string; 단가?: number | null; 글?: string; } export interface SkLine { 차례: number; 행: number | null; 묶음: SkGroup; 이름: string | null; 규격: string; 매개값: Record; 엑셀금액: number | null; 상태: string; 방법: string; 단위: unknown; 후보: SkCand[]; 꼬리표: string[]; 글: string; 우리금액?: number | null; 차?: number | null; } export interface SkBlock { 번: number; 이름: string | null; 단위: string | null; 판정: string; 고를줄: number; 우리금액: number | null; 엑셀금액: number | null; 근거: { 로직: string; 이름: string | null; 판정: string | null } | null; 만든: string | null; 줄: SkLine[]; /** 부르는 일위대가 호표 번(780-1) — [] = 일위대가 밖 · 없음 = 옛 서버(안) */ 부름?: number[]; } export interface SkJudged { 블록: number; 판정: SkBlock[]; 만듦: { 번: number; 이름: string | null; 키: string; 갈래: string; 못옮김: unknown[] }[]; 못만듦: { 번: number; 이름: string | null; 까닭: string }[]; 그대로: number; 같은것씀: { 번: number; 키: string }[]; 위층같음: { 번: number; 층: string; 주인: string | null; 키: string }[]; } /** 확인 목록 한 칸(서버 몸) */ export interface SkSure { 번: number; 고름?: Record; } /** 캐시 꼴 — 블록 번 · 줄 차례는 JSON 열쇠라 글 */ export interface SkDraft { 고름: Record>; 확인: number[]; } export const MINE = "내가 고름"; export const GOOD = "옮길 수 있음"; export const NO_LINES = "줄 없음"; export const LINKED = "이어짐"; export const PICK = "고를 것"; export const OUT = "밖"; export const DROP = "뺌"; export const GROUP: Record = { L: "노무", X: "기계", M: "자재", R: "율", S: "하위", E: "값", }; /** 율 기준 고르기 칸 */ export const BASES = ["인력", "재료", "노무비", "재료비", "경비"]; const DONE = new Set([LINKED, OUT, DROP]); export const emptyDraft = (): SkDraft => ({ 고름: {}, 확인: [] }); /** 캐시 글 → 꼴(틀리면 빈 것) */ export function readDraft(raw: string | null): SkDraft { try { const got = JSON.parse(raw ?? "") as Partial | null; const picks = got?.고름 && typeof got.고름 === "object" ? got.고름 : {}; const sure = Array.isArray(got?.확인) ? got.확인.filter((n) => Number.isInteger(n)) : []; return { 고름: picks, 확인: sure }; } catch { return emptyDraft(); } } /** 고른 것 글 — 키 · 율 기준 · 뺌 까닭 */ export function pickText(p: SkPick): string { if ("뺌" in p) return `${DROP} · ${p.뺌}`; if ("기준" in p) { const base = typeof p.기준 === "string" ? p.기준 : `${p.기준.행.join(" · ")} 행`; return `기준 ${base}${p.비목 ? ` · ${p.비목}` : ""}`; } return "노임" in p ? p.노임 : "기계" in p ? p.기계 : p.자재; } /** 줄 보이기 — 캐시 고름이 서버 상태를 덮음 */ export function lineView( line: SkLine, pick: SkPick | undefined, ): { 상태: string; 꼬리표: string[]; 글: string; mine: boolean } { if (!pick) return { 상태: line.상태, 꼬리표: line.꼬리표, 글: line.글, mine: false }; return { 상태: "뺌" in pick ? DROP : LINKED, 꼬리표: [MINE], 글: pickText(pick), mine: true }; } /** 그 블록 캐시 고름 `{차례: 고름}` */ export const picksOf = (d: SkDraft, 번: number): Record => d.고름[String(번)] ?? {}; /** 남은 고를 줄 수 — 이어짐 · 밖 · 뺌 아닌 줄(캐시 고름 겹침) · 줄 0 블록 = 1(서버 「줄 없음」 고를줄 1) */ export const leftLines = (b: SkBlock, picks: Record): number => b.줄.length ? b.줄.filter((l) => !DONE.has(lineView(l, picks[String(l.차례)]).상태)).length : 1; /** [확인] 켜짐 — 고를 줄 0 */ export const canConfirm = (b: SkBlock, picks: Record): boolean => leftLines(b, picks) === 0; /** 블록 판정 글 — 캐시 겹친 뒤 「옮길 수 있음」 · 「고를 줄 n」 · 「줄 없음」 */ export function verdictOf(b: SkBlock, picks: Record): string { if (!b.줄.length) return NO_LINES; const n = leftLines(b, picks); return n ? `고를 줄 ${n}` : GOOD; } /** 줄 고를 수 있나 — 밖 줄 = 아님 · 하위 · 값 = 뺌만 */ export const pickable = (l: SkLine): boolean => l.상태 !== OUT; export const dropOnly = (l: SkLine): boolean => l.묶음 === "S" || l.묶음 === "E"; /** 서버 후보 → 고름(그 줄 묶음 꼴 · 아니면 null) */ export function candPick(group: SkGroup, c: SkCand): SkPick | null { if (group === "L" && c.키?.startsWith("LB")) return { 노임: c.키 }; if (group === "X" && c.키?.startsWith("EQ")) return { 기계: c.키 }; if (group === "M" && c.자재) return { 자재: c.자재 }; if (group === "R" && c.기준 !== undefined && c.기준 !== null) return { 기준: c.기준, ...(c.비목 ? { 비목: c.비목 } : {}) }; return null; } /** 후보 글 — 「이름 규격 [단위]」 · 율 = 기준 */ export function candText(c: SkCand): string { if (c.기준 !== undefined) return pickText({ 기준: c.기준, ...(c.비목 ? { 비목: c.비목 } : {}) }); const spec = c.규격 ? ` ${c.규격}` : ""; const unit = c.단위 ? ` [${c.단위}]` : ""; return `${c.이름 ?? c.키 ?? c.자재 ?? ""}${spec}${unit}${c.구분 ? ` · ${c.구분}` : ""}`; } /** 캐시 정리 — 고를 줄 남은 블록 · 이미 옮겨 판정 없는 블록은 확인에서 뺌 */ export function tidy(d: SkDraft, blocks: SkBlock[]): SkDraft { const by = new Map(blocks.map((b) => [b.번, b])); const sure = d.확인.filter((n) => { const b = by.get(n); return !!b && canConfirm(b, picksOf(d, n)); }); return { ...d, 확인: [...new Set(sure)].sort((a, b) => a - b) }; } /** [확인한 것 옮기기] 몸 — 확인한 블록만 · 고름은 그 블록 줄에 있는 차례만 */ export function sureBody(d: SkDraft, blocks: SkBlock[]): SkSure[] { const by = new Map(blocks.map((b) => [b.번, b])); return tidy(d, blocks).확인.map((n) => { const have = new Set(by.get(n)!.줄.map((l) => l.차례)); const picks = Object.entries(picksOf(d, n)).filter(([at]) => have.has(Number(at))); return picks.length ? { 번: n, 고름: Object.fromEntries(picks.map(([at, p]) => [Number(at), p])) } : { 번: n }; }); } /** 일위대가 밖 블록 — `부름` 빈 배열 */ export const outside = (b: SkBlock): boolean => !!b.부름 && !b.부름.length; /** 마법사 [끝내기] 글 재료 — 아직 안 옮긴 블록 수 · 그중 일위대가 밖 · 그 블록 고를 줄 합 */ export function waiting(d: SkDraft, blocks: SkBlock[]): { 블록: number; 밖: number; 줄: number } { const open = blocks.filter((b) => !b.만든); const 줄 = open.reduce((n, b) => n + leftLines(b, picksOf(d, b.번)), 0); return { 블록: open.length, 밖: open.filter(outside).length, 줄 }; } export type SkFilter = "all" | "in" | "out" | "open"; /** 거르기 — 모두 · 일위대가 안 · 일위대가 밖 · 고를 줄 있음(안 옮긴 블록 · 캐시 겹침) */ export function filterBlocks(blocks: SkBlock[], d: SkDraft, f: SkFilter): SkBlock[] { if (f === "in") return blocks.filter((b) => !outside(b)); if (f === "out") return blocks.filter(outside); if (f === "open") return blocks.filter((b) => !b.만든 && leftLines(b, picksOf(d, b.번)) > 0); return blocks; } /** 줄 고름 넣기 · 지움(null) — 새 캐시 */ export function setPick(d: SkDraft, 번: number, 차례: number, p: SkPick | null): SkDraft { const mine = { ...picksOf(d, 번) }; if (p) mine[String(차례)] = p; else delete mine[String(차례)]; return { ...d, 고름: { ...d.고름, [String(번)]: mine } }; } /** [확인] 켬 · 끔 — 고를 줄 남으면 안 켬 */ export function setSure(d: SkDraft, b: SkBlock, on: boolean): SkDraft { const rest = d.확인.filter((n) => n !== b.번); const ok = on && canConfirm(b, picksOf(d, b.번)); return { ...d, 확인: ok ? [...rest, b.번].sort((x, y) => x - y) : rest }; }