/* ============================================================================= * M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_SheetMap.ts * 엑셀 보기 창 세 보기 칠 · 뛸 곳(PLAN 779-4 · 요구서 621 3장 4 · 5) — 순수 셈. * · 재료 = 서버 격자 `보기 · 목록 · 줄 · 호표 · 단산`(779-1 `M02_Hopyo_Grid`) · 목록 `줄상태 · 만든`(785-7 `Judge.view` 1차 판정) · * 블록 `[{번, 판정, SK, 갈래, 못옮김}]`(779-2 `M02_Hopyo_Marks` — 칠 꼴 표가 그 머리) · 확인 창 캐시 고름 * · 칠 = success 이어짐 · warning 이어짐 · 엑셀과 다름 · info 고를 것 · danger 못 이음 · muted 밖 + mine(내가 고름 띠) * · 「이어짐」 = 1차 판정 「제안 있음」 · 판정 화면 확인함 · 만든 SC(옛 확인 창 캐시 `draftOf` 는 785-7 부터 늘 null) * · 호표 머리 행 = 그 호표 줄 칠 중 가장 나쁜 것 · 블록 = 만든 SK 갈래 › 없으면 부르는 첫 줄 판정 「예상」 · 안 불림 = 밖 * · 블록 안 줄(779-5) = 블록 `줄 [[행, 상태, 차]]`(Marks · D-2 줄 잇기 상태) → 그 출처 행만 칠 · 머리 행 = 줄 가장 나쁜 칠 · * 「엑셀과 다름」 = 이어졌는데 금액 참고 차만 있을 때 · 줄 재료 없으면 옛 블록 칠(범위 통째) * · 엑셀 셈 어긋남(778-5 D-6) = 블록 `어긋남`(Marks) → 상세 시트 행 차례 목록 — 칠 · 판정에 안 씀(띠 · 글만) * · 뛸 곳 = 일위대가 단산 줄 → 상세 블록 · 목록 행 · (여럿이면) 다른 부르는 줄 / 일위 줄 → 하위 호표 / 목록 행 → 상세 · 부르는 줄 / 상세 → 부르는 줄 · 목록 * 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장). * ========================================================================== */ export type Role = "일위대가표" | "단가산출표" | "단가산출상세"; export const ROLES: Role[] = ["일위대가표", "단가산출표", "단가산출상세"]; export type Tone = "success" | "warning" | "info" | "danger" | "muted"; type Rows = [number, number]; /** 격자 응답 덧 칸(779-1) — 옛 파일엔 없음 */ export interface GridLine { 번: number; 차례: number; 행: number | null; 단산?: { 번: number; 행: Rows }; 일위?: { 번: number; 행: Rows }; } export interface GridMore { 호표: { 번: number; 행: Rows }[]; 단산: { 번: number; 행: Rows }[]; 보기?: Partial>; 원본?: Partial>; 목록?: { 호표: { 번: number; 행: number }[]; 단산: { 번: number; 행: number }[] }; 줄?: GridLine[]; } /** 목록 호표 덧 칸(779-2) — `줄상태 [[차례, 상태, 체크 제안]]` · `만든` SC 키 */ export interface BriefMarks { 번: number; 줄상태?: [number, string, boolean][]; 만든?: string | null; } export interface BlockMark { 번: number; /** 새 블록 판정(785-6 · 기초로 풂 · 기초+덧 · 줄 조합 · 고를 것) › 옛 부르는 첫 줄 맞춤셈 상태 */ 판정: string | null; 고를수?: number | null; 부름?: number[]; SK: string | null; 갈래: string | null; 못옮김: unknown[]; /** 779-5 블록 안 단위 줄 `[출처 행, 줄 잇기 상태, 금액 참고 차]` — 없으면 옛 블록 칠 */ 줄?: [number, string, boolean][]; /** 778-5 D-6 엑셀 셈 어긋남(불리는 블록 · 없으면 null) */ 어긋남?: Miss | null; } export interface Miss { 줄합: number | null; 금액: number | null; 차: number | null; 덮음: boolean | null; 줄: { 행: number | null; 이름: string; 묶음: string; 차: number | null; 까닭: string }[]; 까닭: string; } /** 옛 확인 창 캐시 고름 꼴(785-7 부터 부르는 쪽이 늘 null) */ export interface PickDraft { 체크: Record; 대상: Record; 뺌: Record; } export interface Mark { tone: string; 글: string; } /** 뛸 곳 — 시트 id · 행 [시작, 끝] · 부름 = 여럿(차례로 돌림) */ export interface Spot { 시트: string; 행: Rows; } export interface Jump { 갈래: "상세" | "목록" | "호표" | "부름"; 번: number; 곳: Spot[]; } const RANK: Record = { muted: 0, success: 1, warning: 2, info: 3, danger: 4 }; export const WORD: Record = { success: "이어짐", warning: "이어짐 · 엑셀과 다름", info: "고를 것", danger: "못 이음", muted: "밖", }; /** 줄 칠 — `state` 서버 상태 · `check` 서버 체크 제안 · `d` 캐시 고름 · `at` 줄 차례 */ export function lineTone( state: string, check: boolean, d: PickDraft | null, at: number, ): { tone: Tone; mine: boolean } { const parts = d?.대상[at]; const mine = parts !== undefined || !!d?.뺌[at]; if (d?.뺌[at] || state === "뺌") return { tone: "muted", mine }; const ok = !parts ? true // 캐시 대상 없음 = 서버 제안 대상(체크 제안은 배 확실할 때만) : state === "%" ? !!parts[0]?.비율 : parts.length > 0 && parts.every((p) => p.배 !== null && p.배 > 0); const on = (d?.체크[at] ?? check) && ok; if (state === "%") return { tone: on ? "success" : "muted", mine }; if (on) return { tone: state === "다른 점" && !parts ? "warning" : "success", mine }; return { tone: state === "없음" || state === "노임" ? "danger" : "info", mine }; } /** 가장 나쁜 칠(밖만이면 밖) */ export const worst = (tones: Tone[]): Tone => tones.reduce((a, b) => (RANK[b] > RANK[a] ? b : a), "muted"); /** 새 블록 판정 칠(785-6) — 확인 전이라 제안 칠만(확인함 = 만든 SK) */ const BLOCK_TONE: Record = { "기초로 풂": "success", "기초+덧": "success", "줄 조합": "info", "고를 것": "info" }; // prettier-ignore /** 블록 칠 — 만든 SK 갈래 › 없으면 부르는 첫 줄 판정 「예상」 · 판정 없음 = 「일위대가 밖」 */ export function blockTone(m: BlockMark | undefined): { tone: Tone; 글: string } { if (!m) return { tone: "muted", 글: "안 불림" }; if (m.SK) { const lost = m.못옮김.length; const tone: Tone = lost ? "danger" : m.갈래 === "SK" ? "warning" : "success"; return { tone, 글: `${m.SK} · ${m.갈래 ?? ""}${lost ? ` · 못옮김 ${lost}` : ""}` }; } if (!m.판정) return { tone: "muted", 글: "일위대가 밖" }; // 부르는 일위대가 줄 없음(780-2) const fresh = BLOCK_TONE[m.판정]; if (fresh) { const n = m.판정 === "고를 것" && m.고를수 ? ` ${m.고를수}` : ""; const out = m.부름 && !m.부름.length ? " · 일위대가 밖" : ""; return { tone: fresh, 글: `제안 — ${m.판정}${n}${out}` }; } const tone: Tone = m.판정 === "맞음" ? "success" : m.판정 === "다른 점" ? "warning" : "info"; return { tone, 글: `예상 — 부르는 첫 줄 ${m.판정}` }; } /** 블록 안 줄 칠(779-5) — D-2 줄 잇기 상태 · `diff` = 이어졌는데 엑셀 금액과 참고 차 */ const UNIT_TONE: Record = { 이어짐: "success", "고를 것": "info", "못 이음": "danger", 밖: "muted" }; // prettier-ignore export const unitTone = (state: string, diff: boolean): Tone => { const tone = UNIT_TONE[state] ?? "info"; return tone === "success" && diff ? "warning" : tone; }; /** 보기 셋 찾기표 */ export interface SheetIndex { ids: Partial>; lineAt: Map; hopyo: Map; block: Map; listRow: Map; listAt: Map; callers: Map; } export function indexOf(g: GridMore): SheetIndex { const callers = new Map(); for (const l of g.줄 ?? []) if (l.단산 && l.행) callers.set(l.단산.번, [...(callers.get(l.단산.번) ?? []), l]); const list = g.목록?.단산 ?? []; return { ids: g.보기 ?? {}, lineAt: new Map((g.줄 ?? []).flatMap((l) => (l.행 ? [[l.행, l] as const] : []))), hopyo: new Map(g.호표.map((h) => [h.번, h.행])), block: new Map(g.단산.map((b) => [b.번, b.행])), listRow: new Map(list.map((x) => [x.번, x.행])), listAt: new Map(list.map((x) => [x.행, x.번])), callers, }; } /** 행 범위 안 번(호표 · 블록) */ export const numAt = (spans: Map, row: number): number | null => { for (const [no, [a, b]] of spans) if (a <= row && row <= b) return no; return null; }; /** 시트 · 행에서 뛸 곳 — 없으면 [] */ export function jumpsAt(ix: SheetIndex, sheet: string, row: number): Jump[] { const { 일위대가표: ilwi, 단가산출표: list, 단가산출상세: detail } = ix.ids; const detailOf = (no: number): Jump[] => { const rows = ix.block.get(no); return detail && rows ? [{ 갈래: "상세", 번: no, 곳: [{ 시트: detail, 행: rows }] }] : []; }; const listOf = (no: number): Jump[] => { const at = ix.listRow.get(no); return list && at ? [{ 갈래: "목록", 번: no, 곳: [{ 시트: list, 행: [at, at] }] }] : []; }; const callersOf = (no: number): Jump[] => { const lines = ix.callers.get(no) ?? []; const 곳 = lines.map((l): Spot => ({ 시트: ilwi!, 행: [l.행!, l.행!] })); return ilwi && 곳.length ? [{ 갈래: "부름", 번: no, 곳 }] : []; }; if (sheet === ilwi) { const l = ix.lineAt.get(row); if (l?.단산) { const more = (ix.callers.get(l.단산.번)?.length ?? 0) > 1 ? callersOf(l.단산.번) : []; return [...detailOf(l.단산.번), ...listOf(l.단산.번), ...more]; // 같은 블록 다른 부르는 줄 } if (l?.일위) return [{ 갈래: "호표", 번: l.일위.번, 곳: [{ 시트: ilwi, 행: l.일위.행 }] }]; return []; } if (sheet === list) { const no = ix.listAt.get(row); return no === undefined ? [] : [...detailOf(no), ...callersOf(no)]; } if (sheet === detail) { const no = numAt(ix.block, row); return no === null ? [] : [...callersOf(no), ...listOf(no)]; } return []; } /** 칠 표 — 시트 id → 행(1부터) → {꼴, 글} · `draftOf(번)` = 그 호표 캐시 고름(없으면 null) · * `judged(번, 차례)` = 판정 화면에서 확인함(785-6) · 만든 SC 호표 줄 = 옮김 끝 → 둘 다 초록(밖 줄 그대로) */ export function paintOf( ix: SheetIndex, briefs: BriefMarks[], blocks: BlockMark[], draftOf: (no: number) => PickDraft | null, judged?: (no: number, at: number) => boolean, ): Map> { const out = new Map>(); const put = (sheet: string | undefined, row: number, m: Mark): void => { if (sheet) (out.get(sheet) ?? out.set(sheet, new Map()).get(sheet)!).set(row, m); }; const { 일위대가표: ilwi, 단가산출표: list, 단가산출상세: detail } = ix.ids; const rowOf = new Map(); // 「번:차례」 → 일위대가 행 for (const l of ix.lineAt.values()) rowOf.set(`${l.번}:${l.차례}`, l.행!); for (const h of briefs) { if (!h.줄상태) continue; const d = draftOf(h.번); const tones: Tone[] = []; for (const [at, state, check] of h.줄상태) { const was = lineTone(state, check, d, at); const ok = was.tone !== "muted" && (judged?.(h.번, at) ? "확인함" : h.만든 ? "만듦" : ""); const { tone, mine } = ok ? { tone: "success" as Tone, mine: was.mine } : was; tones.push(tone); const row = rowOf.get(`${h.번}:${at}`); const how = ok ? `${ok} → ${WORD[tone]}` : `${state} → ${WORD[tone]}`; const 글 = `제${h.번}호표 줄 ${at + 1} · ${how}${mine ? " · 내가 고름" : ""}`; if (row) put(ilwi, row, { tone: mine ? `${tone} mine` : tone, 글 }); } const head = ix.hopyo.get(h.번)?.[0]; const tone = worst(tones); const made = h.만든 ? ` · 만듦 ${h.만든}` : ""; if (head && !out.get(ilwi ?? "")?.has(head)) put(ilwi, head, { tone, 글: `제${h.번}호표 · 가장 나쁜 줄 ${WORD[tone]}${made}` }); } const by = new Map(blocks.map((b) => [b.번, b])); for (const [no, [a, b]] of ix.block) { const mark = by.get(no); const at = ix.listRow.get(no); if (mark?.줄?.length) { const tones = mark.줄.map(([row, state, diff]) => { const tone = unitTone(state, diff); const more = diff && state === "이어짐" ? " · 엑셀 금액과 차(참고)" : ""; if (a <= row && row <= b) put(detail, row, { tone, 글: `단산${no} 줄 · ${state} → ${WORD[tone]}${more}` }); // prettier-ignore return tone; }); const tone = worst(tones); const m = { tone, 글: `단산${no} · 가장 나쁜 줄 ${WORD[tone]} · ${blockTone(mark).글}` }; put(detail, a, m); // 머리 행 = 줄 가장 나쁜 칠 if (at) put(list, at, m); continue; } const { tone, 글 } = blockTone(mark); const m = { tone, 글: `단산${no} · ${WORD[tone]} · ${글}` }; for (let r = a; r <= b; r += 1) put(detail, r, m); if (at) put(list, at, m); } return out; } /** 엑셀 셈 어긋남 자리 — 상세 시트 행 차례 · 덮음 실패 = 블록 머리 행 · 재현 못 한 줄 = 그 줄 행 */ export function missesOf( ix: SheetIndex, blocks: BlockMark[], ): { 행: number; 번: number; 글: string }[] { const out: { 행: number; 번: number; 글: string }[] = []; for (const b of blocks) { const m = b.어긋남; const head = ix.block.get(b.번)?.[0]; if (!m || !head) continue; if (m.덮음 === false) out.push({ 행: head, 번: b.번, 글: `단산${b.번} · ${m.까닭}` }); for (const l of m.줄) out.push({ 행: l.행 ?? head, 번: b.번, 글: `단산${b.번} · ${l.이름} · ${l.까닭}` }); } return out.sort((a, b) => a.행 - b.행); }