Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
629 lines
23 KiB
TypeScript
629 lines
23 KiB
TypeScript
/* =============================================================================
|
|
* M02_DF_InputPanel_Build.ts
|
|
* 입력 상자 판의 고르는 규칙 — M01 줄 → 요소 · 입력 값 물음 · 상자 단위 · 변수 검사 · 상자 고치기(새 CfLogic)
|
|
* DOM 없음 · 서버 없음(시험이 Node 로 바로 돌림) · 화면 = `M02_DF_InputPanel.ts`
|
|
* ========================================================================== */
|
|
|
|
import type {
|
|
CfBox,
|
|
CfElementRef,
|
|
CfGroup,
|
|
CfInput,
|
|
CfLogic,
|
|
CfLogicRef,
|
|
CfMaterialMode,
|
|
CfMaterialRule,
|
|
CfPort,
|
|
CfScope,
|
|
CfVariable,
|
|
} from "./M02_DF_Model";
|
|
|
|
/** 입력 상자가 고르는 그룹 — 화면 차례 */
|
|
export const GROUPS: CfGroup[] = [
|
|
"인력",
|
|
"재료",
|
|
"유가전력",
|
|
"기계",
|
|
"소요량",
|
|
"계수",
|
|
"환율",
|
|
"요율",
|
|
];
|
|
|
|
/** 그룹 → M01 저장 그룹 · 파일 이름에 든 낱말(재료 그룹은 파일이 둘) */
|
|
export const M01_OF: Record<CfGroup, { group: string; part: string }> = {
|
|
인력: { group: "인력", part: "" },
|
|
재료: { group: "재료", part: "자재품목" },
|
|
유가전력: { group: "재료", part: "유가전력" },
|
|
기계: { group: "기계", part: "" },
|
|
소요량: { group: "소요량", part: "" },
|
|
계수: { group: "계수", part: "" },
|
|
환율: { group: "환율", part: "" },
|
|
요율: { group: "요율", part: "" },
|
|
};
|
|
|
|
/** M01 줄 한 개 — 파일 그대로(한글 칸) */
|
|
export type M01Row = Record<string, unknown>;
|
|
|
|
/** 사용자 요소 키 머리 — 마스터가 아니라 시험 폴더 `_data` 에서 옴 */
|
|
export const USER_PREFIX = "UE-";
|
|
|
|
/** 기계 요소 한 칸의 값 이름(가격 · 손료계수 …) — ref 는 「EQ000001#가격」 */
|
|
export const MACHINE_SEP = "#";
|
|
export const machineFields = (row: M01Row): string[] =>
|
|
row["값"] && typeof row["값"] === "object" ? Object.keys(row["값"] as object) : [];
|
|
|
|
const text = (v: unknown): string => (v === null || v === undefined ? "" : String(v));
|
|
const dictUnit = (row: M01Row, field: string): string =>
|
|
row["단위"] && typeof row["단위"] === "object"
|
|
? text((row["단위"] as Record<string, unknown>)[field])
|
|
: "";
|
|
|
|
/** 재료 줄 값 열 다섯 가운데 낮은 값 — 없으면 null */
|
|
export function lowest(row: M01Row): number | null {
|
|
const found = ["물가자료", "유통물가", "물가정보", "거래가격"]
|
|
.map((k) => row[k])
|
|
.filter((v): v is number => typeof v === "number");
|
|
return found.length ? Math.min(...found) : null;
|
|
}
|
|
|
|
/** 줄 옆에 보일 값 — 모르면 null */
|
|
export function valueOf(group: CfGroup, row: M01Row, field = "가격"): number | null {
|
|
if (group === "재료") return lowest(row);
|
|
if (group === "기계")
|
|
return typeof (row["값"] as Record<string, unknown> | undefined)?.[field] === "number"
|
|
? (row["값"] as Record<string, number>)[field]
|
|
: null;
|
|
if (group === "요율") {
|
|
const v = row["값"] as Record<string, unknown> | undefined;
|
|
return typeof v?.["rate_percent"] === "number" ? v["rate_percent"] : null;
|
|
}
|
|
return typeof row["값"] === "number" ? row["값"] : null;
|
|
}
|
|
|
|
/** M01 줄 → 입력 상자 요소 — 요율은 비율이 하나뿐인 줄만 「%」 */
|
|
export function elementOf(group: CfGroup, row: M01Row, field = "가격"): CfElementRef {
|
|
const key = text(row["키"]);
|
|
const spec = text(row["규격"]);
|
|
if (key.startsWith(USER_PREFIX))
|
|
return {
|
|
ref: key,
|
|
name: [text(row["이름"]), spec].filter(Boolean).join(" "),
|
|
spec,
|
|
unit: text(row["단위"]) || "-",
|
|
};
|
|
// 환율 · 유가전력의 규격은 날짜라 알약 이름에 안 넣음(날짜가 바뀌어도 이름이 같게) — 유가전력은 상세구분(지역)으로 가름
|
|
const tail = group === "환율" ? "" : group === "유가전력" ? text(row["상세구분"]) : spec;
|
|
const named = [text(row["이름"]), tail].filter(Boolean).join(" "); // 알약 이름 = 이름 + 규격(규격만 다른 요소가 부딪히지 않게)
|
|
if (group === "기계")
|
|
return {
|
|
ref: `${key}${MACHINE_SEP}${field}`,
|
|
name: `${named} · ${field}`,
|
|
spec,
|
|
unit: dictUnit(row, field) || "-",
|
|
};
|
|
if (group === "요율")
|
|
return {
|
|
ref: key,
|
|
name: text(row["이름"]),
|
|
spec: "",
|
|
unit: valueOf(group, row) === null ? "-" : "%",
|
|
};
|
|
return { ref: key, name: named, spec, unit: text(row["단위"]) || "-" };
|
|
}
|
|
|
|
/** 입력 상자가 내보내는 알약 이름 — 요소면 요소 이름 · 표면 「표」 · 변수면 변수 이름(정함) */
|
|
export function pillNames(box: CfBox): string[] {
|
|
const i = box.input;
|
|
if (!i) return [];
|
|
if (i.table) return ["표"];
|
|
if (i.mode === "logic") return (i.logic?.outputs ?? []).map((o) => o.name);
|
|
return i.mode === "variable" ? i.variables.map((v) => v.name) : i.elements.map((e) => e.name);
|
|
}
|
|
|
|
/** 입력 값 물음 — Api `cfValues` 한 줄(id 는 부르는 쪽이 정함) */
|
|
export function queryOf(group: CfGroup, ref: string): Record<string, unknown> {
|
|
if (group === "기계" && !ref.startsWith(USER_PREFIX)) {
|
|
const [key, field] = ref.split(MACHINE_SEP);
|
|
return { group, ref: key, field: field || "가격" };
|
|
}
|
|
return { group, ref };
|
|
}
|
|
|
|
/* ── 재료 요소 방식 셋 · 다른 로직 입력(브레인 정함 — sub7 Model 타입이 오면 그 타입으로 바꿈) ── */
|
|
|
|
/* ── 재료 요소 방식 셋 · 다른 로직 입력 — 타입은 Model(`CfMaterialMode` · `CfScope` · `CfMaterialRule` · `CfLogicRef`) ── */
|
|
|
|
/** 자재 한 품목 — 서버 `/api/m02/df/materials` 한 줄 */
|
|
export interface MatItem {
|
|
ref: string;
|
|
name: string;
|
|
spec: string;
|
|
unit: string;
|
|
price: number | null;
|
|
구분?: string;
|
|
상세구분?: string;
|
|
}
|
|
|
|
const squash = (v: unknown): string =>
|
|
String(v ?? "")
|
|
.replace(/\s+/g, "")
|
|
.toLowerCase();
|
|
|
|
/** 규칙이 이 품목을 잡나 — value = 변수 알약 값 */
|
|
export function matches(
|
|
item: Pick<MatItem, "name" | "spec">,
|
|
rule: Pick<CfMaterialRule, "field" | "op">,
|
|
value: unknown,
|
|
): boolean {
|
|
const v = squash(value);
|
|
if (!v) return false;
|
|
const target = squash(rule.field === "규격" ? item.spec : item.name);
|
|
return rule.op === "같음" ? target === v : target.includes(v);
|
|
}
|
|
|
|
/** 지금 잡히는 것 미리 보기 글 — 0 이면 까닭 · 여럿이면 알림(noun = 재료 · 줄) */
|
|
export function catchNote(
|
|
found: number,
|
|
firstName = "",
|
|
noun = "재료",
|
|
): { kind: "none" | "one" | "many"; text: string } {
|
|
if (found === 0)
|
|
return {
|
|
kind: "none",
|
|
text: `맞는 ${noun}${noun === "줄" ? "이" : "가"} 없음 — 범위나 규칙을 넓혀 봄`,
|
|
};
|
|
if (found === 1) return { kind: "one", text: `${noun} 하나가 잡힘 — ${firstName}` };
|
|
return {
|
|
kind: "many",
|
|
text: `${noun} ${found} 개가 잡힘 — 둘 이상이면 ${noun === "줄" ? "첫 줄" : "가장 싼 값"}을 씀 · 규칙을 더 좁히면 하나로 잡힘`,
|
|
};
|
|
}
|
|
|
|
/** 쪽 범위 — total 개를 size 씩 · page 는 1 부터 · 앞 번호 · 뒤 번호(1 부터) */
|
|
export function pageRange(
|
|
total: number,
|
|
page: number,
|
|
size: number,
|
|
): { from: number; to: number; pages: number } {
|
|
const pages = Math.max(1, Math.ceil(total / size));
|
|
const p = Math.min(Math.max(1, page), pages);
|
|
return { from: total ? (p - 1) * size + 1 : 0, to: Math.min(total, p * size), pages };
|
|
}
|
|
|
|
/** 이 로직의 변수 알약 — 입력 상자 변수마다(규칙의 「알약」 고르기 목록) */
|
|
export function variablePills(
|
|
lg: CfLogic,
|
|
): { port: CfPort; unit: string; example: CfVariable["example"] }[] {
|
|
return lg.boxes.flatMap((b) =>
|
|
b.input?.mode === "variable"
|
|
? b.input.variables.map((v) => ({
|
|
port: { box: b.id, name: v.name },
|
|
unit: v.unit,
|
|
example: v.example,
|
|
}))
|
|
: [],
|
|
);
|
|
}
|
|
|
|
/** 선택 자리 · 조건 찾기 요소 — ref 는 기본 품목(없으면 빈 글) */
|
|
export function materialElement(
|
|
mode: CfMaterialMode,
|
|
name: string,
|
|
scope: CfScope,
|
|
extra: { rule?: CfMaterialRule; item?: Pick<MatItem, "ref" | "spec" | "unit"> } = {},
|
|
): CfElementRef {
|
|
const item = extra.item;
|
|
return {
|
|
ref: mode === "rule" ? "" : (item?.ref ?? ""),
|
|
name: name.trim(),
|
|
slot: name.trim(),
|
|
spec: item?.spec ?? "",
|
|
unit: item?.unit || "-",
|
|
mode,
|
|
scope: Object.fromEntries(Object.entries(scope).filter(([, v]) => v)) as CfScope,
|
|
...(mode === "rule" && extra.rule ? { rule: extra.rule } : {}),
|
|
...(mode === "choice" && item?.ref ? { default: item.ref } : {}),
|
|
};
|
|
}
|
|
|
|
/** 재료 요소를 넣기 전 검사 — 쉬운 말 한 줄 · 괜찮으면 null */
|
|
export function checkMaterial(el: CfElementRef, others: CfElementRef[]): string | null {
|
|
if (!el.name) return "자리 이름을 적어 주세요";
|
|
if (others.some((o) => o.name === el.name)) return "같은 이름의 알약이 이미 있음";
|
|
if (el.mode === "rule" && !el.rule?.from.name) return "규칙에 쓸 변수 알약을 골라 주세요";
|
|
if (
|
|
el.mode !== "fixed" &&
|
|
!(el.mode === "choice" && el.default) &&
|
|
!Object.keys(el.scope ?? {}).length
|
|
)
|
|
return "범위(구분 · 상세구분 · 찾는 말) 하나는 정해 주세요";
|
|
return null;
|
|
}
|
|
|
|
/** 다른 로직에서 읽어 둘 것 — 내보내는 알약 = 출력 상자마다 · 받는 알약 = 설계 때 정할 것(변수 · 재료 자리 · 이어지지 않은 다른 로직 받는 알약)
|
|
* examples = 받는 알약마다 그 로직 자기 예시값(선이 없을 때 값 차례의 끝) */
|
|
export function logicRefOf(key: string, other: CfLogic): CfLogicRef {
|
|
const outputs = other.boxes
|
|
.filter((b) => b.kind === "output")
|
|
.map((b) => ({ name: b.name, unit: b.output?.unit || b.unit || "-" }));
|
|
const designs: { name: string; unit: string }[] = [];
|
|
const examples: Record<string, number | string> = {};
|
|
const addDesign = (name: string, unit: string, example?: number | string | null): void => {
|
|
designs.push({ name, unit: unit || "-" });
|
|
if (example !== null && example !== undefined && example !== "") examples[name] = example;
|
|
};
|
|
for (const b of other.boxes) {
|
|
for (const v of b.input?.variables ?? b.func?.variables ?? [])
|
|
addDesign(v.name, v.unit, v.example);
|
|
if (b.input?.mode === "group")
|
|
for (const e of b.input.elements)
|
|
if (e.mode === "choice" || e.mode === "rule") addDesign(e.slot || e.name, e.unit);
|
|
if (b.input?.mode === "logic")
|
|
for (const d of b.input.logic?.designs ?? [])
|
|
if (!other.links.some((l) => l.to.box === b.id && l.to.name === d.name))
|
|
addDesign(d.name, d.unit, b.input.logic?.examples?.[d.name]);
|
|
}
|
|
const seen = new Set<string>();
|
|
return {
|
|
key,
|
|
name: other.name,
|
|
outputs: outputs.filter((o) => !seen.has(o.name) && !!seen.add(o.name)),
|
|
designs: designs.filter((d) => (seen.has(`d:${d.name}`) ? false : !!seen.add(`d:${d.name}`))),
|
|
...(Object.keys(examples).length ? { examples } : {}),
|
|
};
|
|
}
|
|
|
|
/* ── 마스터 로직 고르기 — 목록 · 거름 · 쪽 · 순환 막음(36-6) ── */
|
|
|
|
/** 마스터 로직 한 줄 — M01 `fetchLogics` 의 줄(자기 시험 로직 DF-… 도 같은 꼴로 합침) */
|
|
export interface MasterLogicBrief {
|
|
file: string;
|
|
구분: string;
|
|
상세구분: string;
|
|
키: string;
|
|
원문번호: string;
|
|
이름: string;
|
|
결과단위: string;
|
|
blocked?: boolean;
|
|
reasons?: string[];
|
|
}
|
|
|
|
/** 찾기 — 띄어쓰기로 나눈 말을 모두 포함(이름 · 원문번호 · 키 · 구분 · 상세구분 · 띄어쓰기는 가리지 않음) · 원문(구분) · 장(상세구분) 거름 */
|
|
export function filterLogics(
|
|
items: MasterLogicBrief[],
|
|
q: { text: string; book?: string; chapter?: string },
|
|
): MasterLogicBrief[] {
|
|
const words = q.text.split(/\s+/).map(squash).filter(Boolean);
|
|
return items.filter((i) => {
|
|
if (q.book && i.구분 !== q.book) return false;
|
|
if (q.chapter && i.상세구분 !== q.chapter) return false;
|
|
const hay = squash(`${i.이름} ${i.원문번호} ${i.키} ${i.구분} ${i.상세구분}`);
|
|
return words.every((w) => hay.includes(w));
|
|
});
|
|
}
|
|
|
|
/** 원문(구분) 목록 · 장(상세구분) 목록 — 나온 차례대로 */
|
|
export function logicBooks(items: MasterLogicBrief[]): string[] {
|
|
return [...new Set(items.map((i) => i.구분).filter(Boolean))];
|
|
}
|
|
export function logicChapters(items: MasterLogicBrief[], book: string): string[] {
|
|
return [
|
|
...new Set(
|
|
items
|
|
.filter((i) => !book || i.구분 === book)
|
|
.map((i) => i.상세구분)
|
|
.filter(Boolean),
|
|
),
|
|
];
|
|
}
|
|
|
|
/** 옛 로직을 흐름도로 바꿔 읽은 것은 출력 상자가 비목별이라 노무비 · 재료비 · 경비 · 계 알약 넷으로 보임 */
|
|
export const COST_OUTPUTS = ["노무비", "재료비", "경비", "계"] as const;
|
|
|
|
/** 마스터 로직 고른 것 → 다른 로직 입력 값 — 흐름이면 출력 상자 · 변환이면 비용 넷 */
|
|
export function masterRef(
|
|
key: string,
|
|
flow: CfLogic,
|
|
converted: boolean,
|
|
resultUnit: string,
|
|
): CfLogicRef {
|
|
const ref = logicRefOf(key, flow);
|
|
return converted
|
|
? { ...ref, outputs: COST_OUTPUTS.map((name) => ({ name, unit: resultUnit || "-" })) }
|
|
: ref;
|
|
}
|
|
|
|
/** 옛 로직 줄(입력 · 자동값)에서 바로 읽은 다른 로직 입력 값 — 흐름 변환이 아직 비어 있을 때 · 받는 알약 = 입력마다 · 예시값 = 자동값 */
|
|
export function rowRef(
|
|
key: string,
|
|
name: string,
|
|
resultUnit: string,
|
|
inputs: { 이름: string; 단위?: string }[],
|
|
auto: Record<string, unknown>,
|
|
): CfLogicRef {
|
|
const examples: Record<string, number | string> = {};
|
|
for (const i of inputs) {
|
|
const v = auto[i.이름];
|
|
if (typeof v === "number" || (typeof v === "string" && v !== "")) examples[i.이름] = v;
|
|
}
|
|
return {
|
|
key,
|
|
name,
|
|
outputs: COST_OUTPUTS.map((n) => ({ name: n, unit: resultUnit || "-" })),
|
|
designs: inputs.map((i) => ({ name: i.이름, unit: i.단위 || "-" })),
|
|
...(Object.keys(examples).length ? { examples } : {}),
|
|
};
|
|
}
|
|
|
|
/** 이 로직이 자기 자신을 부르거나 돌고 도나 — `load` 가 로직을 주면 입력 상자의 「다른 로직」 을 따라가 봄(깊이 8) */
|
|
export async function wouldCycle(
|
|
selfKey: string | null,
|
|
startKey: string,
|
|
load: (key: string) => Promise<CfLogic | null>,
|
|
depth = 8,
|
|
): Promise<boolean> {
|
|
if (!selfKey) return false;
|
|
const seen = new Set<string>();
|
|
const walk = async (key: string, left: number): Promise<boolean> => {
|
|
if (key === selfKey) return true;
|
|
if (seen.has(key) || left <= 0) return false;
|
|
seen.add(key);
|
|
const lg = await load(key).catch(() => null);
|
|
for (const b of lg?.boxes ?? []) {
|
|
const next = b.input?.mode === "logic" ? b.input.logic?.key : undefined;
|
|
if (next && (await walk(next, left - 1))) return true;
|
|
}
|
|
return false;
|
|
};
|
|
return walk(startKey, depth);
|
|
}
|
|
|
|
/** 다른 로직 입력 넣기 · 빼기(null) — 빼면 변수 상태로 돌아가고 이 상자의 알약에 닿은 선은 모두 지움 */
|
|
export function setLogicInput(lg: CfLogic, id: string, ref: CfLogicRef | null): CfLogic {
|
|
return {
|
|
...lg,
|
|
boxes: lg.boxes.map((b) => {
|
|
if (b.id !== id || !b.input) return b;
|
|
const input: CfInput = ref
|
|
? { mode: "logic", elements: [], variables: [], logic: ref }
|
|
: { mode: "variable", elements: [], variables: [] };
|
|
return { ...b, input, unit: unitOfInput(input) };
|
|
}),
|
|
links: lg.links.filter((l) => l.from.box !== id && l.to.box !== id), // 나가던 선 · 그 로직의 받는 알약으로 들어오던 선 모두
|
|
};
|
|
}
|
|
|
|
/** 상자 단위 — 모두 같으면 그 단위 · 여럿이거나 없으면 「-」 */
|
|
export function unitOfInput(input: CfInput): string {
|
|
if (input.table) return "-";
|
|
if (input.mode === "logic") {
|
|
const u = (input.logic?.outputs ?? []).map((o) => o.unit || "-");
|
|
return u.length && u.every((x) => x === u[0]) ? u[0] : "-";
|
|
}
|
|
const units = (
|
|
input.mode === "variable"
|
|
? input.variables.map((v) => v.unit)
|
|
: input.elements.map((e) => e.unit)
|
|
).map((u) => u || "-");
|
|
return units.length && units.every((u) => u === units[0]) ? units[0] : "-";
|
|
}
|
|
|
|
/** 변수 검사 — 쉬운 말 한 줄 · 괜찮으면 null */
|
|
export function checkVariable(v: CfVariable, others: CfVariable[]): string | null {
|
|
const name = v.name.trim();
|
|
if (!name) return "이름을 적어 주세요";
|
|
if (others.some((o) => o.name === name)) return "같은 이름의 변수가 이미 있음";
|
|
if (v.type === "choice" && (v.choices ?? []).length < 2) return "선택 목록은 둘 이상";
|
|
if (v.type === "number") {
|
|
if (v.range && !(v.range[0] < v.range[1])) return "범위는 아래가 위보다 작아야 함";
|
|
if (
|
|
typeof v.example === "number" &&
|
|
v.range &&
|
|
(v.example < v.range[0] || v.example > v.range[1])
|
|
)
|
|
return "예시값이 범위 밖";
|
|
if (v.example !== null && typeof v.example !== "number") return "예시값은 숫자";
|
|
}
|
|
if (
|
|
v.type === "choice" &&
|
|
v.example !== null &&
|
|
!(v.choices ?? []).map(String).includes(String(v.example))
|
|
)
|
|
return "예시값이 선택 목록에 없음";
|
|
return null;
|
|
}
|
|
|
|
/* ── 상자 고치기 — 새 CfLogic 을 돌려줌(받은 것은 안 바꿈) ── */
|
|
|
|
type Pills = (box: CfBox) => string[];
|
|
const defaultPills: Pills = pillNames;
|
|
|
|
const touch = (
|
|
lg: CfLogic,
|
|
id: string,
|
|
fn: (b: CfBox) => CfBox,
|
|
pills: Pills = defaultPills,
|
|
): CfLogic => {
|
|
const old = lg.boxes.find((b) => b.id === id);
|
|
if (!old) return lg;
|
|
const next = fn(old);
|
|
const boxes = lg.boxes.map((b) =>
|
|
b.id === id ? { ...next, unit: next.input ? unitOfInput(next.input) : next.unit } : b,
|
|
);
|
|
// 알약이 없어진 선은 지움
|
|
const keep = new Set(pills(next));
|
|
const links = lg.links.filter((l) => l.from.box !== id || keep.has(l.from.name));
|
|
return { ...lg, boxes, links };
|
|
};
|
|
|
|
const inputOf = (b: CfBox): CfInput => b.input ?? { mode: "group", elements: [], variables: [] };
|
|
|
|
/** 갈래 바꾸기 — 그룹이면 고른 것 비움 · 변수 ↔ 그룹은 서로의 내용을 비움 */
|
|
export function setKind(
|
|
lg: CfLogic,
|
|
id: string,
|
|
kind: CfGroup | "변수" | "다른 로직",
|
|
pills?: Pills,
|
|
): CfLogic {
|
|
return touch(
|
|
lg,
|
|
id,
|
|
(b) => {
|
|
const cur = inputOf(b);
|
|
if (kind === "다른 로직")
|
|
return { ...b, input: { mode: "logic", elements: [], variables: [] }, unit: "-" };
|
|
if (kind === "변수")
|
|
return { ...b, input: { mode: "variable", elements: [], variables: cur.variables } };
|
|
const same = cur.mode === "group" && cur.group === kind;
|
|
return {
|
|
...b,
|
|
input: same ? cur : { mode: "group", group: kind, elements: [], variables: [] },
|
|
};
|
|
},
|
|
pills,
|
|
);
|
|
}
|
|
|
|
/** 요소 넣기 — 같은 요소는 한 번만 */
|
|
export function addElement(lg: CfLogic, id: string, el: CfElementRef, pills?: Pills): CfLogic {
|
|
return touch(
|
|
lg,
|
|
id,
|
|
(b) => {
|
|
const cur = inputOf(b);
|
|
return cur.elements.some((e) => (el.ref !== "" && e.ref === el.ref) || e.name === el.name)
|
|
? b
|
|
: { ...b, input: { ...cur, table: undefined, elements: [...cur.elements, el] } };
|
|
},
|
|
pills,
|
|
);
|
|
}
|
|
|
|
/** 이름으로 빼기 — 재료 조건 찾기 요소는 품목 키가 없어 이름(알약 이름)으로 가름 */
|
|
export function removeElementNamed(lg: CfLogic, id: string, name: string, pills?: Pills): CfLogic {
|
|
return touch(
|
|
lg,
|
|
id,
|
|
(b) => ({
|
|
...b,
|
|
input: { ...inputOf(b), elements: inputOf(b).elements.filter((e) => e.name !== name) },
|
|
}),
|
|
pills,
|
|
);
|
|
}
|
|
|
|
export function removeElement(lg: CfLogic, id: string, ref: string, pills?: Pills): CfLogic {
|
|
return touch(
|
|
lg,
|
|
id,
|
|
(b) => ({
|
|
...b,
|
|
input: { ...inputOf(b), elements: inputOf(b).elements.filter((e) => e.ref !== ref) },
|
|
}),
|
|
pills,
|
|
);
|
|
}
|
|
|
|
/** 표 고르기 — `row` 가 있으면 그 줄 하나 · 요소는 비움 */
|
|
export function setTable(
|
|
lg: CfLogic,
|
|
id: string,
|
|
table: { key: string; row?: number } | null,
|
|
name?: string,
|
|
pills?: Pills,
|
|
): CfLogic {
|
|
const next = touch(
|
|
lg,
|
|
id,
|
|
(b) => ({ ...b, input: { ...inputOf(b), elements: [], table: table ?? undefined } }),
|
|
pills,
|
|
);
|
|
return name
|
|
? {
|
|
...next,
|
|
boxes: next.boxes.map((b) =>
|
|
b.id === id && /^(입력|새)/.test(b.name) ? { ...b, name } : b,
|
|
),
|
|
}
|
|
: next;
|
|
}
|
|
|
|
/** 변수 넣기 · 고치기(이름이 같으면 바꿈) */
|
|
export function putVariable(
|
|
lg: CfLogic,
|
|
id: string,
|
|
v: CfVariable,
|
|
oldName?: string,
|
|
pills?: Pills,
|
|
): CfLogic {
|
|
return touch(
|
|
lg,
|
|
id,
|
|
(b) => {
|
|
const cur = inputOf(b);
|
|
const vars =
|
|
oldName === undefined
|
|
? [...cur.variables, v]
|
|
: cur.variables.map((x) => (x.name === oldName ? v : x));
|
|
return { ...b, input: { ...cur, mode: "variable", group: undefined, variables: vars } };
|
|
},
|
|
pills,
|
|
);
|
|
}
|
|
|
|
export function removeVariable(lg: CfLogic, id: string, name: string, pills?: Pills): CfLogic {
|
|
return touch(
|
|
lg,
|
|
id,
|
|
(b) => ({
|
|
...b,
|
|
input: { ...inputOf(b), variables: inputOf(b).variables.filter((v) => v.name !== name) },
|
|
}),
|
|
pills,
|
|
);
|
|
}
|
|
|
|
/** 상자 단위를 요소 · 변수 단위에 맞춤 — 이미 맞으면 받은 것 그대로(견본처럼 단위가 안 맞게 저장된 상자를 열 때 바로잡음) */
|
|
export function reconcileUnit(lg: CfLogic, id: string): CfLogic {
|
|
const box = lg.boxes.find((b) => b.id === id);
|
|
if (!box?.input) return lg;
|
|
const want = unitOfInput(box.input);
|
|
return box.unit === want
|
|
? lg
|
|
: { ...lg, boxes: lg.boxes.map((b) => (b.id === id ? { ...b, unit: want } : b)) };
|
|
}
|
|
|
|
/* ── 조건 찾기(rule) — 인력 · 기계 · 유가전력도 씀: 이름 또는 키가 변수 알약 값과 같은 줄(옛 로직 LB:{직종} · EQ:{기계} 꼴) ── */
|
|
|
|
/** 조건 찾기를 쓰는 그룹 — 재료는 재료 판(규격 · 이름 포함) · 선택 자리(choice)는 재료만 */
|
|
export const RULE_GROUPS: CfGroup[] = ["인력", "기계", "유가전력"];
|
|
|
|
export type GroupRuleField = "이름" | "키";
|
|
|
|
/** 이 줄이 규칙에 맞나 — 이름 또는 키(원문번호도)가 알약 값과 같음(띄어쓰기 · 대소문자는 가리지 않음) */
|
|
export function matchesRow(row: M01Row, field: GroupRuleField, value: unknown): boolean {
|
|
const v = squash(value);
|
|
if (!v) return false;
|
|
const targets = field === "키" ? [row["키"], row["원문번호"]] : [row["이름"]];
|
|
return targets.some((x) => squash(x) === v);
|
|
}
|
|
|
|
/** 조건 찾기 요소 — 품목 키는 비우고 알약 이름 = 자리 이름 · 규칙 = 같음(Model 규칙 꼴에 「키」 칸을 더해 씀) */
|
|
export function groupRuleElement(
|
|
name: string,
|
|
field: GroupRuleField,
|
|
from: CfMaterialRule["from"],
|
|
): CfElementRef {
|
|
const rule = { field, op: "같음", from } as unknown as CfMaterialRule;
|
|
return { ref: "", name: name.trim(), slot: name.trim(), spec: "", unit: "-", mode: "rule", rule };
|
|
}
|
|
|
|
/** 넣기 전 검사 — 쉬운 말 한 줄 · 괜찮으면 null */
|
|
export function checkGroupRule(el: CfElementRef, others: CfElementRef[]): string | null {
|
|
if (!el.name) return "자리 이름을 적어 주세요";
|
|
if (others.some((o) => o.name === el.name)) return "같은 이름의 알약이 이미 있음";
|
|
if (!el.rule?.from.name) return "규칙에 쓸 변수 알약을 골라 주세요";
|
|
return null;
|
|
}
|