- 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>
228 lines
10 KiB
TypeScript
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, 막힘, 수 };
|
|
}
|