feat(778-5 D-5): 마법사 단가산출 줄 고름 — 제안 · 블록 [확인] 뒤 확인한 것만 옮김(요구 621 · 622 · 623 · 624)

- 단가산출 칸 = 서버 줄 판정(빈 확인) 블록 표: 판정 · 우리 금액 · 엑셀 금액(참고) · 근거 꼬리표 · 만든 SK · [확인] 칩(고를 줄 0 일 때만) · [줄] 펼침 · [보기]
- 줄 표(새 Hopyo_SkLines) = 묶음 · 이름 · 규격 · 매개값 · 상태 칩(엑셀 칠과 한 규칙 unitTone) · 꼬리표 · 방법 · 금액 · 차 · 고름(후보 · 찾기 · 율 기준 · 뺌 + 까닭 · 지움) · 엑셀 보기 뛰기
- 고름 · 확인 = 캐시 m02-hopyo-sk · [확인한 것 옮기기] = 몸 {확인: [{번, 고름}]} · 서버가 다시 셈 · 자동 확인 · 「모두 확인」 없음
- 순수 꼴 = 새 logic/Hopyo_SkPick · 길 = 새 Hopyo_SkApi(Hopyo_Api 안 늘림)
- 마법사 [끝내기] 막힘 글 앞에 「단가산출 확인 안 한 블록 n · 고를 줄 m」(Hopyo_Wizard 한 곳)
- 걷음: 옛 고름 대기 기초 고름 · 줄 비교(Compare.compareView · compareBrief · Api SangeunCompare · CompareLook · CSS) — 쓰는 곳 없음 · 못옮김 창 잇기 만들기(옛 바탕 몸 = 서버가 걷어 422) → 보기만 + 줄 찾기 단추 lineFind 로 다시 씀 · 안 쓰는 문구 39
- 낡은 시험 고침: test_746_2(줄 비교 · 못옮김 요소 단추 시험 걷음 — 기능 걷음) · test_729_3(candLabel · 옛 잇기 몸 기대값 걷음) · test_m02_hopyo_screen(sangeunStep 인자) · 새 test_778_5_sk_pick_screen + helper_778_5_sk_pick.cjs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-04 14:41:48 +09:00
co-authored by Claude Opus 5.5
parent 8380fa7334
commit 3ffeff84c1
16 changed files with 979 additions and 596 deletions
@@ -0,0 +1,223 @@
/* =============================================================================
* 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 일 때만 · 몸 = `{확인: [{번, 고름?}]}`(확인한 블록만 · 자동 확인 없음)
* · 금액 · 판정 정본은 서버 — 여기는 캐시를 겹쳐 보이기 · 몸 짓기만
* 화면 · 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<string, number>;
엑셀금액: 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[];
}
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<number, SkPick>;
}
/** 캐시 꼴 — 블록 번 · 줄 차례는 JSON 열쇠라 글 */
export interface SkDraft {
고름: Record<string, Record<string, SkPick>>;
확인: number[];
}
export const MINE = "내가 고름";
export const GOOD = "옮길 수 있음";
export const LINKED = "이어짐";
export const PICK = "고를 것";
export const OUT = "밖";
export const DROP = "뺌";
export const GROUP: Record<SkGroup, string> = {
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<SkDraft> | 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<string, SkPick> => d.고름[String(번)] ?? {};
/** 남은 고를 줄 수 — 이어짐 · 밖 · 뺌 아닌 줄(캐시 고름 겹침) */
export const leftLines = (b: SkBlock, picks: Record<string, SkPick>): number =>
b.줄.filter((l) => !DONE.has(lineView(l, picks[String(l.차례)]).상태)).length;
/** [확인] 켜짐 — 고를 줄 0 */
export const canConfirm = (b: SkBlock, picks: Record<string, SkPick>): boolean =>
leftLines(b, picks) === 0;
/** 블록 판정 글 — 캐시 겹친 뒤 「옮길 수 있음」 · 「고를 줄 n」 */
export function verdictOf(b: SkBlock, picks: Record<string, SkPick>): string {
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 function waiting(d: SkDraft, blocks: SkBlock[]): { 블록: number; 줄: number } {
const open = blocks.filter((b) => !b.만든);
return { 블록: open.length, 줄: open.reduce((n, b) => n + leftLines(b, picksOf(d, b.번)), 0) };
}
/** 줄 고름 넣기 · 지움(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 };
}