Files
Aislo/M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_SheetMap.ts
eomsangdonandClaude Opus 5.5 cf4e2a0339 refactor(785-7): 옛 맞춤 화면 TS 지움 · 옛 [저장] · [모두 고정] 단추 걷음 · 390 줄 판 높이
- 지움(git rm) 15 파일 3,318 줄: Hopyo_Check · CheckRows · Choose · WizardSk · SkApi · SkLines · logic/SkPick · logic/Draft ·
  Left · Lost · Pick · SheetMake · Kids · ToItem · Compare(모두 옛 확인 창 · 마법사 단가산출 · 안 이은 줄 길 몫 · 부르는 곳 0)
- 단추 판단: 엑셀 보기 [저장](체크 호표) · [모두 고정으로 들여오기] 화면 단추 걷음 → 만들기 = [옮기기] 하나 ·
  모두 고정 서버 길(Builder · build_all)은 B09 · 붙여넣기가 써서 남김 · 목록 체크 칸 · 모달 껍데기(openHopyoSheet · raise · lower) 걷음
- Import: 「안 이은 줄」 띠 · [안 이은 줄 고르기] · 남은 줄 셈 걷음 · [끝내기] = 물음(확인하고 안 옮긴 수 = 판정 고름 캐시 moveBody)
- Api: 옛 맞춤 타입 · 길(readHopyoOne · guessHopyo · buildHopyo · buildHopyoAll · leftHopyo · listHopyoPicks · hopyoSiblings ·
  buildHopyoItem …) 걷음 · POLL_MS · HOPYO_BUILT 옮겨 옴 · 목록 꼴 = 1차 판정 줄상태(Judge.view)
- Labor_Pick: withHopyo(실무 호표 덧줄) → withSangeun(찾아 넣기 창 표시만) · Detail · Table 부름 바꿈
- Ledger: unitField 옮겨 옴 · SheetNav: 옛 확인 캐시 칠 걷음 · UI_Page: [구조물 조합으로] 듣기 걷음
- CSS: 옛 확인 창 · 고르기 창 규칙 34 걷음(481 → 287 줄) · 390 폭 = 줄 판 열리면 위 칸 넓힘 · 줄 판 높이 바닥 16rem

시험 정리(까닭 = 지운 화면 파일만 읽음):
- 지움: test_729_3_hopyo_left_screen · test_746_2_hopyo_compare_screen · test_778_5_sk_pick_screen · helper_729_3 · helper_m02_hopyo_check · pick · seam
- test_m02_hopyo_screen = 좌측 판 · 끝내기 둘만 · 627 · helper_778_5 = SheetSk 판 바꿈만 · 785_4 · 721 · 717_4 · 720 · 739_1 = 지금 꼴로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 01:32:17 +09:00

295 lines
14 KiB
TypeScript

/* =============================================================================
* 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<Record<Role, string>>;
원본?: Partial<Record<Role, string>>;
목록?: { 호표: { 번: 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<number, boolean>;
대상: Record<number, { 배: number | null; 비율?: unknown }[]>;
뺌: Record<number, string>;
}
export interface Mark {
tone: string;
글: string;
}
/** 뛸 곳 — 시트 id · 행 [시작, 끝] · 부름 = 여럿(차례로 돌림) */
export interface Spot {
시트: string;
행: Rows;
}
export interface Jump {
갈래: "상세" | "목록" | "호표" | "부름";
번: number;
곳: Spot[];
}
const RANK: Record<Tone, number> = { muted: 0, success: 1, warning: 2, info: 3, danger: 4 };
export const WORD: Record<Tone, string> = {
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<Tone>((a, b) => (RANK[b] > RANK[a] ? b : a), "muted");
/** 새 블록 판정 칠(785-6) — 확인 전이라 제안 칠만(확인함 = 만든 SK) */
const BLOCK_TONE: Record<string, Tone> = { "기초로 풂": "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<string, Tone> = { 이어짐: "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<Record<Role, string>>;
lineAt: Map<number, GridLine>;
hopyo: Map<number, Rows>;
block: Map<number, Rows>;
listRow: Map<number, number>;
listAt: Map<number, number>;
callers: Map<number, GridLine[]>;
}
export function indexOf(g: GridMore): SheetIndex {
const callers = new Map<number, GridLine[]>();
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<number, Rows>, 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<string, Map<number, Mark>> {
const out = new Map<string, Map<number, Mark>>();
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<string, number>(); // 「번:차례」 → 일위대가 행
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.행);
}