Files
Aislo/M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_Picks.ts
eomsangdonandClaude Sonnet 5.5 e23fec8c9c feat(786-1): 별도계상 줄 품목 고르기 없앰 — 후보 고정 줄 하나 · SC 합 밖 고정 줄
- Row.judge: 별도계상 줄은 M01 · 사전 · 장부 후보 안 찾고 고정 줄 하나(품목 「프로젝트에서」 표지) · 갈래 수 그대로
- Make: 별도계상 고정 줄 = 단가 0 · 메모 「별도계상 · 품목은 프로젝트에서」(합 밖)
- Picks.ts: apartPick 표지 셈
- 시험 642_3 · 642_5 · 642_6 Picks 몫을 새 꼴로 고침(옛 단언: 레미콘 자재 목록 별도계상 칸)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 07:17:50 +09:00

228 lines
10 KiB
TypeScript

/* =============================================================================
* M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_Picks.ts
* 엑셀 불러오기 판정 화면 고름 캐시(PLAN 785-6 · 요구 641 · 642 · 설계 642-A 2-6) — 순수 셈.
* · 캐시 꼴 `{호표 {번: {차례: 고름}}, 블록 {번: 고름}}` · 고름 = `{고른 [고른 것 + 필요?], 확인, 배?}` —
* sessionStorage 에 넣고 빼는 것은 화면(`Hopyo_Rows`) · 여기는 꼴 · 읽기 · 바꾸기만
* · 제안 = 미리 체크로만(캐시에 없으면 서버 제안) · 확인 = 사용자 [확인] · [호표 제안 모두 확인] 누름만(자동 없음)
* · 기초 고른 것 `필요` = 엔진이 쓰는데 못 읽은 칸(입력 필요) + 엇갈림 칸 — 값이 비면 그 줄 확인 · 옮김 막음
* · 옮기기 몸 = 확인한 줄 · 블록만 · `필요` 걷음 · 줄 `배`(단위 환산 사용자 값) 얹음 · 금액 · 품 칸 안 보냄(서버가 다시 셈)
* · 별도계상 줄 = 서버 제안이 고정 줄 하나(`apartPick`) — 고름 없이 [확인] · [옮기기] 됨(막는 칸 없음)
* · 칠 = 확인함 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<string, Record<string, LineDraft>>;
블록: Record<string, LineDraft>;
}
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<JudgeDraft>;
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.출처 },
몫: typeof sec.몫 === "number" ? sec.몫 : (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.장부?.갈래 === "짝") return row.후보.map((p) => ({ ...p }));
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);
/** 별도계상 줄(786-1) — 고정 줄 하나 · 품목은 프로젝트에 복사할 때 정함 · 고름 없이 확인 · 옮김 */
export const apartPick = (p: Pick): boolean => p.갈래 === "고정" && p.별도계상 === true;
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<string | null>): 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, 막힘, 수 };
}