Merge branch 'sub_laptop_3' of C:/Aislo_wt/sub_laptop_3 into main_laptop_1
This commit is contained in:
@@ -16,6 +16,7 @@ from M01_MasterData import M01_MasterData_Store_Make as make
|
||||
_ERR = (store.cm.mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError)
|
||||
|
||||
import master_equation as meq # noqa: E402 — `store` 가 scripts 를 길에 둔 뒤
|
||||
import master_equation_link as link # noqa: E402
|
||||
import master_equation_edit as me # noqa: E402
|
||||
|
||||
|
||||
@@ -81,6 +82,11 @@ def preview(key: str, edits: list, inputs: dict | None = None) -> dict:
|
||||
return {"되나": True, "까닭": [], "줄": new, "수식": eq, "전": before, "후": eq["합계"]}
|
||||
|
||||
|
||||
def links(ask: dict) -> dict:
|
||||
"""새 기호 연결 후보(800-3) — {갈래: 표 | 요소 | 로직, 검색, 분류?} → {후보, 더있음} · 읽기만."""
|
||||
return link.candidates(store.loaded()[1], ask)
|
||||
|
||||
|
||||
def save(
|
||||
key: str, edits: list, inputs: dict | None = None, version: str = "", confirmed: bool = False
|
||||
) -> dict:
|
||||
|
||||
@@ -372,6 +372,18 @@ def post_logic_equation_preview(body: EquationBody) -> dict:
|
||||
return _call(equation.preview, body.key, body.고침, body.inputs)
|
||||
|
||||
|
||||
class LinksBody(BaseModel):
|
||||
갈래: str
|
||||
검색: str = ""
|
||||
분류: str = "인력"
|
||||
|
||||
|
||||
@router.post("/logic/equation/links")
|
||||
def post_logic_equation_links(body: LinksBody) -> dict:
|
||||
"""새 기호 연결 후보(800-3) — 표 · 요소 · 로직 이름 찾기(읽기만)."""
|
||||
return _call(equation.links, body.model_dump())
|
||||
|
||||
|
||||
@router.post("/logic/equation/save")
|
||||
def post_logic_equation_save(body: EquationBody) -> dict:
|
||||
"""수식 고침 저장 — 검사 통과 뒤 `/save` 길(정본 = 개정확인 · 판 다르면 409)."""
|
||||
|
||||
@@ -67,3 +67,47 @@
|
||||
.m01-eq__why {
|
||||
margin-top: var(--spacing-8);
|
||||
}
|
||||
|
||||
/* 800-2 — 왼쪽 수식 영역 · 오른쪽 변수 정의 영역 */
|
||||
.m01-eq__split {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(14rem, 20rem);
|
||||
gap: var(--spacing-12);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.m01-eq__left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@container (max-width: 700px) {
|
||||
.m01-eq__split {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.m01-eq__split > .ui-eq__defs {
|
||||
border-left: 0;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
}
|
||||
|
||||
.m01-eq__def-edit,
|
||||
.m01-eq__draft {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-8);
|
||||
border: 1px solid var(--color-border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
.m01-eq__def-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
* M01 단가산출 로직 상세 「수식」 탭(PLAN 796-2 · 요구 655 · 656 · 657) — PPT 수식 꼴(결과명 = 이름 식 = 값 식 = 결과 + 변수 풀이).
|
||||
* 그림 = 공통 수식 부품(`ui_template_equation*.ts`) · 서버 나무 = `POST /api/m01/logic/equation`(796-1)
|
||||
* 위 = 옵션 가로(도구줄 + 값 칸 · 바꾸면 다시 셈) · 합계 · 고침 도구(시스템 관리자만)
|
||||
* 고침 = 줄을 고르면 그 줄만 식 칸(공용 `sheet_formula_cell` · 칠 때마다 `/equation/preview` 검사) + 숨은 아이콘 줄(분수 · 괄호 · 첨자 · %)
|
||||
* · 변수를 누르면 변수 창(`_EquationVar`) · 고친 목록(`edits`)은 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `/equation/save`(개정 확인)
|
||||
* 배치(800-2) = 왼쪽 수식 영역(줄 구분 없이 식이 이어짐 + 소계 표) · 오른쪽 변수 정의 영역(고른 옵션 갈래의 변수만 · 값 · 단위 · DB 열쇠 없음)
|
||||
* 고침(800-3) = 수식 영역의 식 칸을 눌러 그 자리에서(공용 `sheet_formula_cell` · 칠 때마다 `/equation/preview` 검사 · 틀리면 그 자리 빨강 + 까닭)
|
||||
* · 새 기호를 쓰면 정의 영역에 그 기호 줄이 생겨 연결 지정(`_EquationDefs` · 고침 `연결`) · 이름 · 기호 · 첨자 고침 · 지움도 정의 줄에서
|
||||
* · 고친 목록(`edits`)은 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `/equation/save`(개정 확인)
|
||||
* 계산 자리 = 서버 한 벌(③) — 화면은 고침 목록만 보내고 서버가 다시 셈한 나무를 다시 그림(옛 요청 끊음)
|
||||
* 시트 · 시험 쪽에 저장 안 한 고침이 있으면 수식 고침은 잠김(두 갈래가 한 줄을 따로 고치지 않게)
|
||||
* 페이지 갈래(`page` · 796-2b M02 단가산출 사본) = 같은 부품 · 길 · 고침 목록(문서 `수식고침`) · 옵션 값이 페이지 것 · 저장 = 페이지 [저장]
|
||||
@@ -13,16 +15,18 @@
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { createBanner } from "@ui/ui_template_banner";
|
||||
import { createBadge } from "@ui/ui_template_badge";
|
||||
import { createButton, createIconButton } from "@ui/ui_template_button";
|
||||
import { createButton } from "@ui/ui_template_button";
|
||||
import { createEqChain } from "@ui/ui_template_equation_line";
|
||||
import { createEqDefs, defSign, flashEqDef } from "@ui/ui_template_equation_defs";
|
||||
import { createEqSum } from "@ui/ui_template_equation_sum";
|
||||
import {
|
||||
eqText,
|
||||
type EqLine,
|
||||
type EqSumHooks,
|
||||
type EqNode,
|
||||
type EqTree,
|
||||
type EqVarRow,
|
||||
} from "@ui/ui_template_equation";
|
||||
import { lineId } from "@ui/ui_template_equation_line";
|
||||
import {
|
||||
el,
|
||||
hideLoadingOverlay,
|
||||
@@ -38,15 +42,15 @@ import { createFormulaCell, type FormulaVerdict } from "../A00_Common/sheet_form
|
||||
import { createValueControl } from "../M02_MasterTemplete/M02_Labor_Values";
|
||||
import type { ItemInput } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
|
||||
import { ApiError, type LogicRow } from "./M01_MasterData_UI_Logic_Api";
|
||||
import { openAddDialog, openVarDialog } from "./M01_MasterData_UI_LogicLab_EquationVar";
|
||||
import { saveRevised } from "./M01_MasterData_UI_LogicLab_Revise";
|
||||
import {
|
||||
formulaEdit,
|
||||
inputsOf,
|
||||
insertInto,
|
||||
type EqEdit,
|
||||
type InsertKind,
|
||||
} from "./logic/M01_Logic_Equation_Model";
|
||||
draftRow,
|
||||
renameEditor,
|
||||
type Draft,
|
||||
type DefsHost,
|
||||
} from "./M01_MasterData_UI_LogicLab_EquationDefs";
|
||||
import { openAddDialog } from "./M01_MasterData_UI_LogicLab_EquationVar";
|
||||
import { saveRevised } from "./M01_MasterData_UI_LogicLab_Revise";
|
||||
import { formulaEdit, inputsOf, unknownOf, type EqEdit } from "./logic/M01_Logic_Equation_Model";
|
||||
import "./M01_MasterData_UI_LogicLab_Equation.css";
|
||||
|
||||
const TEXT = {
|
||||
@@ -65,13 +69,11 @@ const TEXT = {
|
||||
],
|
||||
Options: ["옵션", "Options"],
|
||||
Wait: ["수식을 불러오는 중", "Loading equation"],
|
||||
Close: ["식 닫기", "Close formula"],
|
||||
Hint: ["줄을 눌러 고침 · 키보드 + − × ÷ / ( )", "Click a line to edit · keyboard + − × ÷ / ( )"],
|
||||
Frac: ["분수 (위) / (아래)", "Fraction"],
|
||||
Paren: ["괄호", "Parentheses"],
|
||||
Sub: ["아래첨자 _", "Subscript"],
|
||||
Sup: ["위첨자 ^", "Superscript"],
|
||||
Percent: ["% (/ 100)", "Percent"],
|
||||
Hint: [
|
||||
"식을 눌러 바로 고침 · 키보드 + − × ÷ / ( )",
|
||||
"Click a formula to edit · keyboard + − × ÷ / ( )",
|
||||
],
|
||||
Apply: ["연결 적용 · 식 넣기", "Apply links and formula"],
|
||||
Leave: ["저장 안 한 수식 고침 {n} 건 — 버리고 나감?", "Discard {n} unsaved equation edits?"],
|
||||
Leave_Ok: ["버림", "Discard"],
|
||||
Saved: ["수식을 저장함", "Equation saved"],
|
||||
@@ -117,7 +119,7 @@ const loadCache = (): Record<string, EqEdit[]> => {
|
||||
/** 페이지 캐시 갈래(796-2b M02 단가산출 사본) — 길 · 고침 목록 · 옵션 값이 페이지 것 · 저장 = 페이지 [저장] */
|
||||
export interface EqPage {
|
||||
ask: <T>(
|
||||
suffix: "" | "/preview",
|
||||
suffix: "" | "/preview" | "/links",
|
||||
body: Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<T>;
|
||||
@@ -140,6 +142,15 @@ export interface LabEquationOptions {
|
||||
page?: EqPage;
|
||||
}
|
||||
|
||||
/** 식에 처음 쓴 새 기호 — 연결을 고르면 고침 `연결` + 그 식 고침을 한 벌로 보냄 */
|
||||
interface PendingLink {
|
||||
line: EqLine;
|
||||
text: string;
|
||||
symbols: string[];
|
||||
rows: Map<string, Draft>;
|
||||
apply: HTMLElement;
|
||||
}
|
||||
|
||||
export function createLabEquation(opt: LabEquationOptions) {
|
||||
const bar = el("div", { className: "m01-eq__bar" });
|
||||
const optionsBar = el("div", { className: "m01-eq__options", attrs: { "data-eq-options": "" } });
|
||||
@@ -159,7 +170,10 @@ export function createLabEquation(opt: LabEquationOptions) {
|
||||
let inputs: Record<string, string> = {};
|
||||
let before: Record<string, unknown> | null = null;
|
||||
let why = "";
|
||||
let selected: string | null = null;
|
||||
let editingLine: string | null = null;
|
||||
let defEditing: string | null = null;
|
||||
let pending: PendingLink | null = null;
|
||||
let defsBox: HTMLElement = el("div");
|
||||
let ctl: AbortController | null = null;
|
||||
let timer = 0;
|
||||
let paintedBlocked = false;
|
||||
@@ -168,7 +182,11 @@ export function createLabEquation(opt: LabEquationOptions) {
|
||||
const canEdit = (): boolean => opt.admin && !opt.blocked();
|
||||
const page = opt.page;
|
||||
const vals = (): Record<string, string> => page?.values() ?? inputs;
|
||||
const ask = <T>(suffix: "" | "/preview", body: Record<string, unknown>, signal?: AbortSignal) =>
|
||||
const ask = <T>(
|
||||
suffix: "" | "/preview" | "/links",
|
||||
body: Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
) =>
|
||||
page ? page.ask<T>(suffix, body, signal) : post<T>(`/logic/equation${suffix}`, body, signal);
|
||||
|
||||
const body0 = (): Record<string, unknown> => ({ key, inputs: inputsOf(vals()), 고침: edits });
|
||||
@@ -302,14 +320,97 @@ export function createLabEquation(opt: LabEquationOptions) {
|
||||
);
|
||||
}
|
||||
|
||||
// ── 고침 도구 ──────────────────────────────────────────────────────
|
||||
const ICONS: [InsertKind, string, "Frac" | "Paren" | "Sub" | "Sup" | "Percent"][] = [
|
||||
["frac", "a⁄b", "Frac"],
|
||||
["paren", "( )", "Paren"],
|
||||
["sub", "x₂", "Sub"],
|
||||
["sup", "x²", "Sup"],
|
||||
["percent", "%", "Percent"],
|
||||
];
|
||||
// ── 바로 고침 · 연결 지정 ──────────────────────────────────────────
|
||||
const total = (sums: Record<string, unknown>): string => ("계" in sums ? "계" : "결과");
|
||||
|
||||
const defsHost: DefsHost = {
|
||||
commit,
|
||||
links: (body) => ask("/links", body),
|
||||
onDropped: (k) => {
|
||||
if (page) page.setValue(k, null);
|
||||
else {
|
||||
delete inputs[k];
|
||||
drawOptions();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
function buildDefs(): HTMLElement {
|
||||
const edit = canEdit();
|
||||
const mids = (eq?.변수표 ?? []).filter((v) => v.갈래 === "중간").map((v) => v.열쇠);
|
||||
return createEqDefs(eq?.정의 ?? [], {
|
||||
editing: defEditing,
|
||||
onEdit: edit
|
||||
? (d) => {
|
||||
defEditing = defEditing === defSign(d) ? null : defSign(d);
|
||||
drawDefs();
|
||||
}
|
||||
: undefined,
|
||||
editor: (d) =>
|
||||
renameEditor(d, defsHost, () => {
|
||||
defEditing = null;
|
||||
drawDefs();
|
||||
}),
|
||||
drafts: pending ? [...[...pending.rows.values()].map((d) => d.root), pending.apply] : [],
|
||||
head: edit
|
||||
? [
|
||||
createButton({
|
||||
label: t("Add"),
|
||||
variant: "ghost",
|
||||
size: 24,
|
||||
iconStart: "plus",
|
||||
onClick: () => openAddDialog({ mids, commit }),
|
||||
}),
|
||||
]
|
||||
: [],
|
||||
});
|
||||
}
|
||||
|
||||
function drawDefs(): void {
|
||||
const next = buildDefs();
|
||||
defsBox.replaceWith(next);
|
||||
defsBox = next;
|
||||
}
|
||||
|
||||
/** 식 검사가 「모르는 기호」 를 알려 오면 정의 영역에 그 기호 줄을 세움(같은 기호들이면 입력한 것을 지키며 글만 갱신) */
|
||||
function setPending(line: EqLine, text: string, miss: string[]): void {
|
||||
if (!miss.length) {
|
||||
if (!pending) return;
|
||||
pending = null;
|
||||
return drawDefs();
|
||||
}
|
||||
if (pending && pending.symbols.join("\u0000") === miss.join("\u0000")) {
|
||||
[pending.line, pending.text] = [line, text];
|
||||
return;
|
||||
}
|
||||
const rows = new Map(miss.map((m) => [m, draftRow(m, defsHost)] as const));
|
||||
const apply = el("div", {
|
||||
className: "m01-eq__def-actions",
|
||||
children: [createButton({ label: t("Apply"), size: 24, onClick: () => void applyPending() })],
|
||||
});
|
||||
pending = { line, text, symbols: miss, rows, apply };
|
||||
drawDefs();
|
||||
defsBox.scrollTo({ top: 0 });
|
||||
}
|
||||
|
||||
async function applyPending(): Promise<void> {
|
||||
const p = pending;
|
||||
if (!p) return;
|
||||
const list: EqEdit[] = [];
|
||||
let missing = false;
|
||||
for (const d of p.rows.values()) {
|
||||
const made = d.read();
|
||||
d.say(typeof made === "string" ? made : "");
|
||||
if (typeof made === "string") missing = true;
|
||||
else list.push(made);
|
||||
}
|
||||
if (missing) return;
|
||||
const bad = await commit([...list, formulaEdit(p.line, p.text)]);
|
||||
if (bad.length) return [...p.rows.values()].forEach((d) => d.say(bad.join(" · ")));
|
||||
drafts.delete(lineId(p.line));
|
||||
[pending, editingLine] = [null, null];
|
||||
paint();
|
||||
}
|
||||
|
||||
const check =
|
||||
(line: EqLine) =>
|
||||
@@ -319,19 +420,26 @@ export function createLabEquation(opt: LabEquationOptions) {
|
||||
{ ...body0(), 고침: [...edits, formulaEdit(line, text)] },
|
||||
signal,
|
||||
);
|
||||
const miss = got.되나 ? [] : unknownOf(got.까닭 ?? []);
|
||||
setPending(line, text, miss);
|
||||
const now = got.후 ? eqText(got.후[total(got.후)]) : "";
|
||||
return {
|
||||
되나: !!got.되나,
|
||||
까닭: got.까닭 ?? [],
|
||||
까닭: miss.length
|
||||
? [`모르는 기호 ${miss.join(" · ")} — 오른쪽 변수 정의에서 연결을 지정`]
|
||||
: (got.까닭 ?? []),
|
||||
알림: got.되나 && now ? [`${t("Total")} ${now}`] : [],
|
||||
식: got.되나 ? text : null,
|
||||
};
|
||||
};
|
||||
|
||||
const total = (sums: Record<string, unknown>): string => ("계" in sums ? "계" : "결과");
|
||||
|
||||
/** 수식 영역의 식 칸 자리에 들어가는 식 칸 — 바꾸면 미리 셈 · 되면 고침 목록에 굳힘 */
|
||||
function editor(line: EqLine, id: string): HTMLElement {
|
||||
const shown = line.입력글 ?? line.식;
|
||||
const close = (): void => {
|
||||
[editingLine, pending] = [null, null];
|
||||
paint();
|
||||
};
|
||||
const cell = createFormulaCell({
|
||||
key: id,
|
||||
spot: id,
|
||||
@@ -340,82 +448,28 @@ export function createLabEquation(opt: LabEquationOptions) {
|
||||
drafts,
|
||||
check: check(line),
|
||||
put: (text) => {
|
||||
if (text === null) return;
|
||||
if (text === null) return pending ? undefined : close();
|
||||
void commit([formulaEdit(line, text)]).then((bad) => {
|
||||
if (bad.length) showToast(bad.join(" · "), "error");
|
||||
else close();
|
||||
});
|
||||
},
|
||||
className: "m01-eq__fx",
|
||||
});
|
||||
const input = (): HTMLInputElement | null => cell.querySelector("input");
|
||||
const icons = ICONS.map(([kind, glyph, label]) =>
|
||||
createIconButton({
|
||||
glyph,
|
||||
title: t(label),
|
||||
size: 24,
|
||||
onClick: () => {
|
||||
const box = input();
|
||||
if (!box) return;
|
||||
const from = box.selectionStart ?? box.value.length;
|
||||
const got = insertInto(kind, box.value, from, box.selectionEnd ?? from);
|
||||
box.value = got.text;
|
||||
box.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
box.focus();
|
||||
box.setSelectionRange(got.caret, got.caret);
|
||||
},
|
||||
}),
|
||||
const input = cell.querySelector("input");
|
||||
// 안 바꾸고 다른 데를 누르면 닫음 · 바꾼 글이 있거나 연결 지정 중이면 그대로
|
||||
cell.addEventListener("focusout", () =>
|
||||
window.setTimeout(() => {
|
||||
if (editingLine === id && !pending && !cell.contains(document.activeElement)) {
|
||||
if ((input?.value.trim() ?? shown) === shown) close();
|
||||
}
|
||||
}, 120),
|
||||
);
|
||||
const add = createButton({
|
||||
label: t("Add"),
|
||||
variant: "ghost",
|
||||
size: 24,
|
||||
iconStart: "plus",
|
||||
onClick: () =>
|
||||
openAddDialog({
|
||||
mids: (eq?.변수표 ?? []).filter((v) => v.갈래 === "중간").map((v) => v.열쇠),
|
||||
commit,
|
||||
}),
|
||||
});
|
||||
const close = createButton({
|
||||
label: t("Close"),
|
||||
variant: "ghost",
|
||||
size: 24,
|
||||
onClick: () => ((selected = null), paint()),
|
||||
});
|
||||
// 아이콘 줄 — 고른 줄에만(평소 숨김)
|
||||
const tools = createToolbar({
|
||||
items: [...icons, "divider", add, "spacer", close],
|
||||
size: 24,
|
||||
label: t("Hint"),
|
||||
});
|
||||
tools.dataset.eqTools = id;
|
||||
const wrap = el("div", { className: "m01-eq__edit", children: [cell, tools] });
|
||||
wrap.dataset.eqEditor = id;
|
||||
queueMicrotask(() => input()?.focus());
|
||||
return wrap;
|
||||
queueMicrotask(() => input?.focus());
|
||||
return cell;
|
||||
}
|
||||
|
||||
function varDialog(node: { 열쇠?: string }): void {
|
||||
const v = (eq?.변수표 ?? []).find((x: EqVarRow) => x.열쇠 === node.열쇠);
|
||||
if (!v || !canEdit()) return;
|
||||
openVarDialog({
|
||||
row: v,
|
||||
value: vals()[v.열쇠] ?? "",
|
||||
commit,
|
||||
onValue: (value) => {
|
||||
if (page) return page.setValue(v.열쇠, value || null);
|
||||
if (value) inputs[v.열쇠] = value;
|
||||
else delete inputs[v.열쇠];
|
||||
drawOptions();
|
||||
void refresh();
|
||||
},
|
||||
onDropped: (k) => {
|
||||
if (page) return page.setValue(k, null);
|
||||
delete inputs[k];
|
||||
drawOptions();
|
||||
},
|
||||
});
|
||||
}
|
||||
const showDef = (node: EqNode): void => flashEqDef(defsBox, node);
|
||||
|
||||
// ── 그림 ──────────────────────────────────────────────────────────
|
||||
function drawBar(): void {
|
||||
@@ -466,24 +520,31 @@ export function createLabEquation(opt: LabEquationOptions) {
|
||||
return;
|
||||
}
|
||||
const edit = canEdit();
|
||||
const sum = createEqSum(eq, open, page?.sum);
|
||||
body.replaceChildren(
|
||||
createEqChain(eq.줄, {
|
||||
path: "",
|
||||
open,
|
||||
selected,
|
||||
resultUnit: eq.결과단위,
|
||||
onSelect: edit
|
||||
? (_line, id) => {
|
||||
selected = selected === id ? null : id;
|
||||
paint();
|
||||
}
|
||||
: undefined,
|
||||
onVar: edit ? varDialog : undefined,
|
||||
slot: edit ? editor : undefined,
|
||||
}),
|
||||
...(sum ? [sum] : []), // 799-1 소계 · 끝수 · 합계
|
||||
);
|
||||
const keys = eq.줄.filter((l) => l.갈래 === "호표").map((l) => l.열쇠);
|
||||
const sum = createEqSum(eq, open, page?.sum, keys);
|
||||
const left = el("div", {
|
||||
className: "m01-eq__left",
|
||||
children: [
|
||||
createEqChain(eq.줄, {
|
||||
path: "",
|
||||
open,
|
||||
resultUnit: eq.결과단위,
|
||||
onVar: edit ? undefined : showDef, // 고칠 수 있으면 식 어디를 눌러도 그 자리 고침(변수 칩 포함)
|
||||
editing: editingLine,
|
||||
onEdit: edit
|
||||
? (_line, id) => {
|
||||
editingLine = id;
|
||||
paint();
|
||||
}
|
||||
: undefined,
|
||||
editor: edit ? editor : undefined,
|
||||
after: (line) => (line.갈래 === "호표" ? (sum?.inline.get(line.열쇠) ?? null) : null),
|
||||
}),
|
||||
...(sum ? [sum.tail] : []), // 799-1 · 800-4 묶음 소계 · 끝수 · 합계
|
||||
],
|
||||
});
|
||||
defsBox = buildDefs();
|
||||
body.replaceChildren(el("div", { className: "m01-eq__split", children: [left, defsBox] }));
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
@@ -494,7 +555,7 @@ export function createLabEquation(opt: LabEquationOptions) {
|
||||
post("/logic/equation/save", { ...body0(), version, 개정확인: ok }),
|
||||
);
|
||||
if (!done) return; // 개정 확인 취소 = 고침 그대로
|
||||
[edits, selected] = [[], null];
|
||||
[edits, editingLine] = [[], null];
|
||||
persist();
|
||||
showToast(t("Saved"), "success");
|
||||
await refresh();
|
||||
@@ -514,7 +575,7 @@ export function createLabEquation(opt: LabEquationOptions) {
|
||||
|
||||
async function discard(): Promise<void> {
|
||||
edits = [];
|
||||
selected = null;
|
||||
[editingLine, defEditing, pending] = [null, null, null];
|
||||
drafts.clear();
|
||||
persist();
|
||||
await refresh();
|
||||
@@ -529,7 +590,8 @@ export function createLabEquation(opt: LabEquationOptions) {
|
||||
version = nextVersion;
|
||||
row = nextRow;
|
||||
if (nextKey !== key) {
|
||||
[key, eq, rowNow, before, why, selected] = [nextKey, null, null, null, "", null];
|
||||
[key, eq, rowNow, before, why, editingLine] = [nextKey, null, null, null, "", null];
|
||||
[defEditing, pending] = [null, null];
|
||||
inputs = {};
|
||||
edits = page ? page.edits() : (loadCache()[nextKey] ?? []);
|
||||
open.clear();
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_LogicLab_EquationDefs.ts
|
||||
* 수식 탭 변수 정의 영역 속 고침(PLAN 800-3 · 요구 660-5) — 796-2a 변수 창을 정의 영역 안으로 옮겨 다시 씀.
|
||||
* ① 이름 · 기호 · 첨자 고침 · 지움 = 정의 줄 아래 칸(`renameEditor`) — 쓰는 변수 지움은 서버가 막고 쓰는 곳을 알림
|
||||
* ② 새 기호 연결 지정 = 식에 처음 쓴 기호마다 줄(`draftRow`) — 국문 이름 + 연결(입력 · 표 찾기 · 요소 · 로직 · 상수) → 고침 `연결` 한 건
|
||||
* 표 · 요소 · 로직 후보 = 서버 `…/equation/links`(읽기만) · 연결 글(엔진 식)은 서버가 지음 — 화면은 고른 것만 보냄
|
||||
* 기호 글 쪼개기(`symbolParts`) = 서버 키보드 식 변수 글(`master_equation_edit._TOKEN` 기호_아래^위)과 같은 꼴 — 시험 `test_800_2_equation_layout`
|
||||
* 값 · 단위는 고치지 않음(요구 660-6) · 계산 · 검사는 서버 한 벌
|
||||
* ========================================================================== */
|
||||
|
||||
import type { EqDef } from "@ui/ui_template_equation";
|
||||
import { createBanner } from "@ui/ui_template_banner";
|
||||
import { createButton } from "@ui/ui_template_button";
|
||||
import { el } from "@ui/ui_template_elements_base";
|
||||
import { createInputField, createSelectField } from "@ui/ui_template_input";
|
||||
import { varEdits, type EqEdit } from "./logic/M01_Logic_Equation_Model";
|
||||
|
||||
export interface Candidate {
|
||||
키: string;
|
||||
이름: string;
|
||||
규격?: string;
|
||||
조건?: string[];
|
||||
칸?: string[];
|
||||
입력?: string[];
|
||||
}
|
||||
|
||||
export interface DefsHost {
|
||||
commit: (edits: EqEdit[]) => Promise<string[]>;
|
||||
links: (ask: Record<string, unknown>) => Promise<{ 후보: Candidate[]; 더있음: boolean }>;
|
||||
onDropped: (key: string) => void;
|
||||
}
|
||||
|
||||
/** 새 기호 글 → 열쇠 · 기호 · 아래 · 위(`Q_d^2` → 열쇠 `Q_d2` · 기호 Q · 아래 d · 위 2) */
|
||||
export function symbolParts(token: string): Pick<EqDef, "열쇠" | "기호" | "아래" | "위"> {
|
||||
const [main = "", up = ""] = token.split("^");
|
||||
const [sign = "", ...low] = main.split("_");
|
||||
return { 열쇠: token.replace(/[^\p{L}\p{N}_]/gu, ""), 기호: sign, 아래: low.join("_"), 위: up };
|
||||
}
|
||||
|
||||
const grid = (...kids: HTMLElement[]): HTMLElement =>
|
||||
el("div", { className: "m01-eq__form", children: kids });
|
||||
|
||||
const field = (label: string, value = ""): ReturnType<typeof createInputField> =>
|
||||
createInputField({ label, value, size: 24 });
|
||||
|
||||
const reasonBox = (): { root: HTMLElement; say: (list: string[]) => void } => {
|
||||
const root = el("div", { className: "m01-eq__why" });
|
||||
return {
|
||||
root,
|
||||
say: (list) =>
|
||||
root.replaceChildren(
|
||||
...(list.length
|
||||
? [createBanner({ tone: "danger", title: "안 됨", text: list.join(" · ") })]
|
||||
: []),
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
/** 정의 줄 고침 칸 — 이름 · 기호 · 위 · 아래 · [적용] · [변수 지움] */
|
||||
export function renameEditor(d: EqDef, host: DefsHost, close: () => void): HTMLElement {
|
||||
const name = field("이름", d.이름);
|
||||
const sign = field("기호", d.기호);
|
||||
const up = field("위첨자", d.위);
|
||||
const down = field("아래첨자", d.아래);
|
||||
const why = reasonBox();
|
||||
const form = (v: Pick<EqDef, "이름" | "기호" | "위" | "아래">) => ({
|
||||
열쇠: d.열쇠,
|
||||
...v,
|
||||
단위: "",
|
||||
});
|
||||
const apply = createButton({
|
||||
label: "적용",
|
||||
size: 24,
|
||||
onClick: async () => {
|
||||
const edits = varEdits(
|
||||
form(d),
|
||||
form({
|
||||
이름: name.input.value.trim(),
|
||||
기호: sign.input.value.trim(),
|
||||
위: up.input.value.trim(),
|
||||
아래: down.input.value.trim(),
|
||||
}),
|
||||
);
|
||||
const bad = edits.length ? await host.commit(edits) : [];
|
||||
why.say(bad);
|
||||
if (!bad.length) close();
|
||||
},
|
||||
});
|
||||
const drop = createButton({
|
||||
label: "변수 지움",
|
||||
variant: "danger-ghost",
|
||||
size: 24,
|
||||
onClick: async () => {
|
||||
const bad = await host.commit([{ 할일: "지움", 열쇠: d.열쇠 }]);
|
||||
why.say(bad);
|
||||
if (!bad.length) host.onDropped(d.열쇠);
|
||||
},
|
||||
});
|
||||
const shut = createButton({ label: "닫기", variant: "ghost", size: 24, onClick: close });
|
||||
const box = el("div", {
|
||||
className: "m01-eq__def-edit",
|
||||
children: [
|
||||
grid(name.root, sign.root, up.root, down.root),
|
||||
el("div", { className: "m01-eq__def-actions", children: [drop, shut, apply] }),
|
||||
why.root,
|
||||
],
|
||||
});
|
||||
box.dataset.eqDefEditor = d.기호;
|
||||
return box;
|
||||
}
|
||||
|
||||
export interface Draft {
|
||||
root: HTMLElement;
|
||||
/** 연결 지정 고침 — 아직 덜 골랐으면 까닭 글 */
|
||||
read: () => EqEdit | string;
|
||||
say: (why: string) => void;
|
||||
}
|
||||
|
||||
const KINDS = [
|
||||
{ value: "입력", text: "입력 (옵션 칸에 생김)" },
|
||||
{ value: "표", text: "표 찾기 (표 · 조건 · 칸)" },
|
||||
{ value: "요소", text: "요소 (인력 · 재료 · 기계)" },
|
||||
{ value: "로직", text: "로직 부르기" },
|
||||
{ value: "상수", text: "상수 (고정 수)" },
|
||||
];
|
||||
const CATS = [
|
||||
{ value: "인력", text: "인력" },
|
||||
{ value: "재료", text: "재료" },
|
||||
{ value: "기계", text: "기계" },
|
||||
];
|
||||
|
||||
/** 후보 찾기 — 검색 글을 칠수록 서버 후보 · 고르기 */
|
||||
function pick(
|
||||
host: DefsHost,
|
||||
ask: () => Record<string, unknown>,
|
||||
onPick: (c: Candidate | null) => void,
|
||||
label: string,
|
||||
): { root: HTMLElement; get: () => Candidate | null; refresh: () => void } {
|
||||
const box = field(label);
|
||||
const list = createSelectField({
|
||||
options: [{ value: "", text: "— 검색해서 고름 —" }],
|
||||
size: 24,
|
||||
ariaLabel: label,
|
||||
});
|
||||
let found: Candidate[] = [];
|
||||
let timer = 0;
|
||||
const get = (): Candidate | null => found[Number(list.select.value)] ?? null;
|
||||
const refresh = (): void => {
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(async () => {
|
||||
const got = await host.links({ ...ask(), 검색: box.input.value.trim() }).catch(() => null);
|
||||
found = got?.후보 ?? [];
|
||||
const more = got?.더있음 ? [{ value: "", text: "… 더 있음 — 검색을 더 칠 것" }] : [];
|
||||
list.setOptions(
|
||||
[
|
||||
{ value: "", text: found.length ? "— 고름 —" : "— 없음 —" },
|
||||
...found.map((c, i) => ({
|
||||
value: String(i),
|
||||
text: `${c.이름}${c.규격 ? ` · ${c.규격}` : ""}`,
|
||||
})),
|
||||
...more,
|
||||
],
|
||||
"",
|
||||
);
|
||||
onPick(null);
|
||||
}, 250);
|
||||
};
|
||||
box.input.addEventListener("input", refresh);
|
||||
list.select.addEventListener("change", () => onPick(list.select.value === "" ? null : get()));
|
||||
return {
|
||||
root: grid(box.root, list.root),
|
||||
get: () => (list.select.value === "" ? null : get()),
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
|
||||
type Reader = () => EqEdit | string;
|
||||
|
||||
const inputKind = (): { nodes: HTMLElement[]; read: Reader } => {
|
||||
const base = field("기본값 (빈 칸 = 옵션에서 고름)");
|
||||
const text = (): string => base.input.value.trim();
|
||||
return {
|
||||
nodes: [base.root],
|
||||
read: () => ({ 할일: "연결", 갈래: "입력", ...(text() ? { 기본값: text() } : {}) }),
|
||||
};
|
||||
};
|
||||
|
||||
const constKind = (): { nodes: HTMLElement[]; read: Reader } => {
|
||||
const val = field("값");
|
||||
return {
|
||||
nodes: [val.root],
|
||||
read: () =>
|
||||
val.input.value.trim()
|
||||
? { 할일: "연결", 갈래: "상수", 값: val.input.value.trim() }
|
||||
: "상수 값을 적을 것",
|
||||
};
|
||||
};
|
||||
|
||||
const elementKind = (host: DefsHost): { nodes: HTMLElement[]; read: Reader } => {
|
||||
const cat = createSelectField({ label: "분류", options: CATS, value: "인력", size: 24 });
|
||||
const find = pick(
|
||||
host,
|
||||
() => ({ 갈래: "요소", 분류: cat.select.value }),
|
||||
() => undefined,
|
||||
"요소 이름",
|
||||
);
|
||||
cat.select.addEventListener("change", find.refresh);
|
||||
find.refresh();
|
||||
return {
|
||||
nodes: [cat.root, find.root],
|
||||
read: () => {
|
||||
const one = find.get();
|
||||
return one ? { 할일: "연결", 갈래: "요소", 요소: one.키 } : "요소를 고를 것";
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const logicKind = (host: DefsHost): { nodes: HTMLElement[]; read: Reader } => {
|
||||
const sub = grid();
|
||||
const args = new Map<string, ReturnType<typeof createInputField>>();
|
||||
const find = pick(
|
||||
host,
|
||||
() => ({ 갈래: "로직" }),
|
||||
(c) => {
|
||||
args.clear();
|
||||
sub.replaceChildren(
|
||||
...(c?.입력 ?? []).map((n) => {
|
||||
const f = field(`${n} = (식 · 빈 칸 = 기본)`);
|
||||
args.set(n, f);
|
||||
return f.root;
|
||||
}),
|
||||
);
|
||||
},
|
||||
"로직 이름",
|
||||
);
|
||||
return {
|
||||
nodes: [find.root, sub],
|
||||
read: () => {
|
||||
const one = find.get();
|
||||
if (!one) return "부를 로직을 고를 것";
|
||||
const 인자 = Object.fromEntries([...args].map(([n, f]) => [n, f.input.value.trim()]));
|
||||
return { 할일: "연결", 갈래: "로직", 로직: one.키, 인자 };
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const tableKind = (host: DefsHost): { nodes: HTMLElement[]; read: Reader } => {
|
||||
const sub = grid();
|
||||
const conds = new Map<string, ReturnType<typeof createInputField>>();
|
||||
const cols = createSelectField({ label: "칸", options: [{ value: "", text: "—" }], size: 24 });
|
||||
const find = pick(
|
||||
host,
|
||||
() => ({ 갈래: "표" }),
|
||||
(c) => {
|
||||
conds.clear();
|
||||
cols.setOptions(
|
||||
[{ value: "", text: "— 칸 —" }, ...(c?.칸 ?? []).map((v) => ({ value: v, text: v }))],
|
||||
"",
|
||||
);
|
||||
sub.replaceChildren(
|
||||
...(c?.조건 ?? []).map((n) => {
|
||||
const f = field(`조건 ${n} = (변수 기호 · 식)`);
|
||||
conds.set(n, f);
|
||||
return f.root;
|
||||
}),
|
||||
);
|
||||
},
|
||||
"표 이름",
|
||||
);
|
||||
find.refresh();
|
||||
return {
|
||||
nodes: [find.root, cols.root, sub],
|
||||
read: () => {
|
||||
const one = find.get();
|
||||
if (!one || !cols.select.value) return "표와 칸을 고를 것";
|
||||
const 조건 = Object.fromEntries([...conds].map(([n, f]) => [n, f.input.value.trim()]));
|
||||
return { 할일: "연결", 갈래: "표", 표: one.키, 칸: cols.select.value, 조건 };
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/** 새 기호 줄 — 국문 이름 + 연결 고르기 · `read` 가 고침 `연결` 한 건을 줌 */
|
||||
export function draftRow(token: string, host: DefsHost): Draft {
|
||||
const parts = symbolParts(token);
|
||||
const name = field("국문 이름");
|
||||
const kind = createSelectField({ label: "연결", options: KINDS, value: "입력", size: 24 });
|
||||
const body = grid();
|
||||
const why = reasonBox();
|
||||
let read: Reader = () => "연결을 고를 것";
|
||||
const mount = (): void => {
|
||||
const made = { 입력: inputKind, 상수: constKind }[kind.select.value as "입력" | "상수"]?.();
|
||||
const picked =
|
||||
made ??
|
||||
{ 요소: elementKind, 로직: logicKind, 표: tableKind }[
|
||||
kind.select.value as "요소" | "로직" | "표"
|
||||
](host);
|
||||
body.replaceChildren(...picked.nodes);
|
||||
read = picked.read;
|
||||
};
|
||||
kind.select.addEventListener("change", mount);
|
||||
mount();
|
||||
const head = el("div", {
|
||||
className: "m01-eq__def-actions",
|
||||
children: [
|
||||
el("span", { className: "ui-text ui-text--muted", text: `새 기호 ${token} — 연결을 지정` }),
|
||||
],
|
||||
});
|
||||
const root = el("div", {
|
||||
className: "ui-eq__def m01-eq__draft",
|
||||
children: [head, grid(name.root, kind.root), body, why.root],
|
||||
});
|
||||
root.dataset.eqDraft = token;
|
||||
return {
|
||||
root,
|
||||
say: (text) => why.say(text ? [text] : []),
|
||||
read: () => {
|
||||
const made = read();
|
||||
if (typeof made === "string") return made;
|
||||
const nameText = name.input.value.trim();
|
||||
return { ...made, ...parts, ...(nameText ? { 이름: nameText } : {}) };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
import type { EqLine } from "@ui/ui_template_equation";
|
||||
|
||||
export interface EqEdit {
|
||||
할일: "식" | "더함" | "지움" | "변수" | "표시";
|
||||
할일: "식" | "더함" | "지움" | "변수" | "표시" | "연결";
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -112,3 +112,12 @@ export function addEdit(form: VarForm, after?: string): EqEdit {
|
||||
} else if (form.기본값) out.기본값 = form.기본값;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 서버 까닭 「… · 모르는 기호 「A」 「B」 — …」 → 새 기호 글 목록(`master_equation_edit._set_formula` 가 같은 글을 냄) */
|
||||
export function unknownOf(reasons: string[]): string[] {
|
||||
for (const why of reasons) {
|
||||
const hit = /모르는 기호((?:\s*「[^」]+」)+)/.exec(why);
|
||||
if (hit?.[1]) return [...hit[1].matchAll(/「([^」]+)」/g)].map((m) => m[1] ?? "");
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -163,6 +163,11 @@ def read(doc: Any, inputs: dict | None = None) -> dict[str, Any]:
|
||||
return out | ({"reason": lost[0]["까닭"]} if lost else {})
|
||||
|
||||
|
||||
def links(doc: Any, ask: dict) -> dict[str, Any]:
|
||||
"""새 기호 연결 후보(800-3) — M01 과 같은 후보 길(읽기만)."""
|
||||
return _eqn().links(ask)
|
||||
|
||||
|
||||
def preview(doc: Any, edits: list, inputs: dict | None = None) -> dict[str, Any]:
|
||||
"""고침 통째 미리 셈(저장 없음) — {되나, 까닭, 줄, 수식, 전(고침 없는 사본 SK 금액), 후}."""
|
||||
from M02_MasterTemplete import M02_Sangeun_Equation_Full as full
|
||||
|
||||
@@ -251,6 +251,26 @@ def sangeun_equation(
|
||||
return _call(_root(session, layer, owner), _sd(eqm.read), body.문서, body.inputs)
|
||||
|
||||
|
||||
class LinksBody(BaseModel):
|
||||
문서: dict[str, Any] | None = None
|
||||
갈래: str
|
||||
검색: str = ""
|
||||
분류: str = "인력"
|
||||
블록: int | None = None
|
||||
|
||||
|
||||
@router.post("/layers/{layer}/sangeun/equation/links")
|
||||
def sangeun_equation_links(
|
||||
layer: Layer,
|
||||
body: LinksBody,
|
||||
owner: str | None = None,
|
||||
session: Session = Depends(verify_session),
|
||||
) -> dict:
|
||||
"""새 기호 연결 후보(800-3) — 표 · 요소 · 로직 이름 찾기(읽기만)."""
|
||||
ask = {k: getattr(body, k) for k in ("갈래", "검색", "분류")}
|
||||
return _call(_root(session, layer, owner), _sd(eqm.links), body.문서 or {}, ask)
|
||||
|
||||
|
||||
@router.post("/layers/{layer}/sangeun/equation/preview")
|
||||
def sangeun_equation_preview(
|
||||
layer: Layer,
|
||||
|
||||
@@ -28,7 +28,9 @@ from pathlib import Path
|
||||
|
||||
import master_auto as ma
|
||||
import master_cut as mcut
|
||||
import master_equation_view as view
|
||||
import master_formula as mf
|
||||
import master_material as mm
|
||||
|
||||
from common_util.common_util_money import fmt
|
||||
|
||||
@@ -195,10 +197,18 @@ def _raw(node) -> str:
|
||||
return me.raw_text(node)
|
||||
|
||||
|
||||
def me_keyboard(ctx: _Ctx, node) -> str:
|
||||
"""조건 식 마디 → 키보드 한 줄 글(변수 = 기호) — 정의 영역 「조건 규격 = 폼타이규격」."""
|
||||
import master_equation_edit as me
|
||||
|
||||
return me.keyboard_of(_raw(node), ctx.row)
|
||||
|
||||
|
||||
def _find(ctx: _Ctx, node, column: str) -> dict:
|
||||
"""찾기(표, 조건).칸 → 찾기 변수 · 표 · 고른 행."""
|
||||
raw = _raw(("field", node, column))
|
||||
more = {"식": raw, "칸": column}
|
||||
more["조건식"] = {k: me_keyboard(ctx, c) for k, c in node[2]}
|
||||
try:
|
||||
ref = mf.fill(node[1], ctx.env)
|
||||
table_row = ctx.master.get(ref)
|
||||
@@ -451,6 +461,11 @@ def export(master, key: str, given: dict | None = None, depth: int = 0) -> dict:
|
||||
total = {k: cut.get(k) for k in mf.BUNDLE}
|
||||
if depth == 0:
|
||||
sub = {"소계": subtotals(row, cut, total)}
|
||||
defs = (
|
||||
{"정의": view.definitions(out_lines, row, table(row, master), _unused(row))}
|
||||
if depth == 0
|
||||
else {}
|
||||
)
|
||||
return _clean(
|
||||
{
|
||||
"열쇠": row.get("키"),
|
||||
@@ -461,10 +476,18 @@ def export(master, key: str, given: dict | None = None, depth: int = 0) -> dict:
|
||||
"변수표": table(row, master),
|
||||
"합계": total,
|
||||
**sub,
|
||||
**defs,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _unused(row: dict):
|
||||
"""열쇠 → 어느 식에도 안 쓰이나(정의 영역 · 지울 수 있는 입력)."""
|
||||
import master_equation_edit as me
|
||||
|
||||
return lambda key: key != mm.REGION and not me.uses(row, key)
|
||||
|
||||
|
||||
def _item(ctx: _Ctx, at: int, item: dict, line: dict) -> dict:
|
||||
"""호표 줄 = 「단가 × 수량식 = 금액」 · 로직 줄 단가 = 블록."""
|
||||
name = str(item.get("이름") or "")
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
{할일: 지움, 열쇠} — 쓰는 식이 있으면 막고 쓰는 곳 목록
|
||||
{할일: 변수, 열쇠, 이름?, 기호?, 위?, 아래?, 단위?} — 식은 열쇠로 부르니 안 깨짐
|
||||
{할일: 표시, 열쇠, 표시: 0~6 | null}
|
||||
{할일: 연결, 열쇠, 갈래: 입력|표|요소|로직|상수, …} — 새 기호의 연결 지정(`master_equation_link.py` 머리)
|
||||
고친 중간 줄은 `표시` 없으면 `SHOW`(보이는 자리에서 잘라 셈 · 요구 658) — 고치지 않은 줄은 그대로.
|
||||
키보드 한 줄 식 = 변수 글(기호_아래^위 · 이름 · 열쇠) · `× ÷ −` · 나머지는 엔진 식 말 그대로
|
||||
(`[노임 …]` · `[별칭]` 은 M01 · M02 층이 `M02_Sangeun_Formula.to_engine` 으로 먼저 풂).
|
||||
@@ -22,6 +23,7 @@ import re
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
import master_equation as meq
|
||||
import master_equation_link as link
|
||||
import master_formula as mf
|
||||
|
||||
KEY_FORM = re.compile(r"^[^\W\d][\w]{0,19}$") # 열쇠 = 글자로 시작 · 20 자까지
|
||||
@@ -227,12 +229,39 @@ def _text_of(ed: dict, row: dict, words) -> tuple[str, list[str]]:
|
||||
return words(text, row) if words and text else (text, [])
|
||||
|
||||
|
||||
def unknown(text: str, row: dict) -> list[str]:
|
||||
"""엔진 식 글 안 모르는 변수 글(변수 · 열쇠 · 예약어 · 함수 · 칸 · 인자 이름 · 요소 키 빼고)."""
|
||||
known = set(_names(row)[0]) | _all_keys(row) | RESERVED
|
||||
out, at = [], 0
|
||||
while at < len(text):
|
||||
if text[at] in "'[":
|
||||
end = text.find("'" if text[at] == "'" else "]", at + 1)
|
||||
at = len(text) if end < 0 else end + 1
|
||||
continue
|
||||
m = None if at and text[at - 1].isalnum() else _TOKEN.match(text, at)
|
||||
if not m:
|
||||
at += 1
|
||||
continue
|
||||
word, before, rest = m.group(), text[:at].rstrip(), text[m.end() :].lstrip()
|
||||
at = m.end()
|
||||
arg = rest.startswith("=") and not rest.startswith("==")
|
||||
skip = before.endswith(".") or rest.startswith("(") or arg
|
||||
if not (skip or word in known or mf.mk.KEY.fullmatch(word) or word in out):
|
||||
out.append(word)
|
||||
return out
|
||||
|
||||
|
||||
def _set_formula(row: dict, ed: dict, why: list, spots: set, words=None) -> None:
|
||||
spot = str(ed.get("자리") or "")
|
||||
text, bad = _text_of(ed, row, words)
|
||||
if bad:
|
||||
why += [f"{spot} · {w}" for w in bad]
|
||||
return
|
||||
if not isinstance(ed.get("나무"), dict) and (miss := unknown(text, row)):
|
||||
why.append(
|
||||
f"{spot} · 모르는 기호 「{'」 「'.join(miss)}」 — 변수 정의 영역에서 연결을 지정"
|
||||
)
|
||||
return
|
||||
if not text:
|
||||
why.append(f"{spot} · 식이 빔")
|
||||
return
|
||||
@@ -383,6 +412,9 @@ def apply(master, row: dict, edits: list, words=None) -> tuple[dict, list[str],
|
||||
_look(new, key, ed, why)
|
||||
elif job == "표시":
|
||||
_show(new, key, ed, why)
|
||||
elif job == "연결":
|
||||
add, bad = link.plan(master, ed)
|
||||
_add(new, add, why, spots, words) if add else why.append(bad)
|
||||
else:
|
||||
why.append(f"모르는 할일 「{job}」")
|
||||
except (mf.FormulaError, KeyError, TypeError, ValueError) as e:
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""수식 변수 연결 지정(PLAN 800-3 · 요구 660-5) — 새 기호가 무엇과 이어지는지 고르는 서버 몫.
|
||||
|
||||
고침 `{할일: 연결, 열쇠, 갈래, 이름?, 기호?, 위?, 아래?, 뒤?, …}` 을 기존 `더함` 한 건으로 풂(`plan`):
|
||||
입력 {기본값?, 단위?} → 입력 변수(옵션 칸에 생김)
|
||||
표 {표, 칸, 조건: {열: 식글}} → 중간 줄 「찾기(표, 열=식…).칸」
|
||||
요소 {요소: 키} → 중간 줄 「키」(인력 · 재료 · 기계 단가)
|
||||
로직 {로직: 키, 인자?: {칸: 식}} → 중간 줄 「로직(키, 칸=식…)」
|
||||
상수 {값: 수} → 중간 줄 수
|
||||
후보 목록 = `candidates`(표 · 요소 · 로직) — 읽기만 · 값 · 열쇠는 이름 옆 보관용(화면은 이름만 보임).
|
||||
셈 · 검사는 `master_equation_edit.apply` 가 한 벌로(여기는 글만 짓고 막을 것만 막음).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
LIMIT = 30
|
||||
KINDS = ("입력", "표", "요소", "로직", "상수")
|
||||
ELEMS = {"인력": ("LB",), "재료": ("MT", "MO"), "기계": ("EQ",)}
|
||||
TABLES = ("QC", "QF")
|
||||
_KEY = re.compile(r"[A-Z]{2}\d{6}")
|
||||
_COL = re.compile(r"^[^\W\d][\w]*$")
|
||||
|
||||
|
||||
def _q(word: str) -> str:
|
||||
return word if _COL.match(word) else f"'{word}'"
|
||||
|
||||
|
||||
def _row(master, key: str):
|
||||
try:
|
||||
return master.get(key)
|
||||
except Exception: # noqa: BLE001 — 마스터 없는 열쇠 = 어떤 오류든 「없음」
|
||||
return None
|
||||
|
||||
|
||||
def plan(master, ed: dict) -> tuple[dict | None, str]:
|
||||
"""연결 고침 → (더함 고침, 까닭) — 까닭이 있으면 더함 없음."""
|
||||
kind = ed.get("갈래")
|
||||
add = {
|
||||
"할일": "더함",
|
||||
**{k: ed[k] for k in ("열쇠", "이름", "기호", "위", "아래", "단위", "뒤") if k in ed},
|
||||
}
|
||||
if kind not in KINDS:
|
||||
return None, f"연결 갈래 「{kind}」 — {' · '.join(KINDS)}"
|
||||
if kind == "입력":
|
||||
add |= {"갈래": "입력"} | (
|
||||
{"기본값": ed["기본값"]} if ed.get("기본값") not in (None, "") else {}
|
||||
)
|
||||
return add, ""
|
||||
add["갈래"] = "중간"
|
||||
if kind == "상수":
|
||||
try:
|
||||
add["식"] = format(Decimal(str(ed.get("값"))).normalize(), "f")
|
||||
except (InvalidOperation, ValueError):
|
||||
return None, "상수는 수로 적어야 함"
|
||||
elif kind == "요소":
|
||||
key = str(ed.get("요소") or "")
|
||||
if (
|
||||
not _KEY.fullmatch(key)
|
||||
or key[:2] not in sum(ELEMS.values(), ())
|
||||
or not _row(master, key)
|
||||
):
|
||||
return None, "요소를 골라야 함(인력 · 재료 · 기계)"
|
||||
add["식"] = key
|
||||
elif kind == "로직":
|
||||
key = str(ed.get("로직") or "")
|
||||
if not _KEY.fullmatch(key) or not key.startswith("G") or not _row(master, key):
|
||||
return None, "부를 로직을 골라야 함"
|
||||
args = "".join(
|
||||
f", {_q(k)}={v}" for k, v in (ed.get("인자") or {}).items() if str(v).strip()
|
||||
)
|
||||
add["식"] = f"로직({key}{args})"
|
||||
else:
|
||||
key, col = str(ed.get("표") or ""), str(ed.get("칸") or "")
|
||||
table = _row(master, key) if _KEY.fullmatch(key) else None
|
||||
if not table or "조건" not in table or col not in (table.get("값칸") or {}):
|
||||
return None, "표와 칸을 골라야 함"
|
||||
given = ed.get("조건") or {}
|
||||
miss = [c for c in table["조건"] if not str(given.get(c) or "").strip()]
|
||||
if miss:
|
||||
return None, f"표 조건을 채워야 함 — {' · '.join(miss)}"
|
||||
cond = "".join(f", {_q(c)}={given[c]}" for c in table["조건"])
|
||||
add["식"] = f"찾기({key}{cond}).{_q(col)}"
|
||||
return add, ""
|
||||
|
||||
|
||||
def _hit(text: object, words: list[str]) -> bool:
|
||||
low = str(text or "").lower()
|
||||
return all(w in low for w in words)
|
||||
|
||||
|
||||
def candidates(master, ask: dict) -> dict:
|
||||
"""후보 {갈래: 표 | 요소 | 로직, 검색, 분류?(요소)} → {후보: [{키, 이름, …}]} — 최대 `LIMIT` 건."""
|
||||
kind, words = ask.get("갈래"), str(ask.get("검색") or "").lower().split()
|
||||
out: list[dict] = []
|
||||
if kind == "표":
|
||||
pools = [(k, r) for p in TABLES for k, r in master.index.get(p, {}).items() if "조건" in r]
|
||||
for key, r in pools:
|
||||
if _hit(r.get("이름"), words):
|
||||
out.append(
|
||||
{
|
||||
"키": key,
|
||||
"이름": r.get("이름"),
|
||||
"조건": list(r["조건"]),
|
||||
"칸": list(r.get("값칸") or {}),
|
||||
}
|
||||
)
|
||||
elif kind == "요소":
|
||||
for p in ELEMS.get(str(ask.get("분류") or "인력"), ()):
|
||||
for key, r in master.index.get(p, {}).items():
|
||||
if (words or p != "MT") and _hit(f"{r.get('이름')} {r.get('규격') or ''}", words):
|
||||
out.append({"키": key, "이름": r.get("이름"), "규격": r.get("규격") or ""})
|
||||
elif kind == "로직":
|
||||
for key, r in master.index.get("GC", {}).items():
|
||||
if words and _hit(r.get("이름"), words):
|
||||
out.append(
|
||||
{
|
||||
"키": key,
|
||||
"이름": r.get("이름"),
|
||||
"입력": [s["이름"] for s in r.get("입력") or []],
|
||||
}
|
||||
)
|
||||
return {"후보": out[:LIMIT], "더있음": len(out) > LIMIT}
|
||||
@@ -0,0 +1,133 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""수식 변수 정의 영역(PLAN 800-2 · 요구 660-2 · 3 · 6) — 수식 나무 → 오른쪽 「변수 정의」 목록.
|
||||
|
||||
줄 구분 없이 한 목록 · 같은 변수는 한 번만(겹침 합침) · 기호 : 국문 이름 — 연결(입력 · 표 찾기 ·
|
||||
요소 · 로직 · 계산을 사람 말로) · 값 · 단위 · DB 열쇠 글은 담지 않음.
|
||||
지금 고른 옵션 갈래만 — 만약의 탄 갈래 표지 `걸림`(1 참 · 2 거짓 · 0 모름 = 둘 다)을 그대로 씀(새 판정 없음) ·
|
||||
`안씀` = 어느 식에도 안 쓰이는 입력(지울 수 있음) · 쓰는 변수 지움 막기는 `master_equation_edit._drop`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
CAT = {"LB": "인력", "EQ": "기계", "MT": "재료", "MO": "재료"}
|
||||
WHAT = {
|
||||
"입력": "입력",
|
||||
"중간": "계산",
|
||||
"결과": "계산 결과",
|
||||
"찾기": "표 찾기",
|
||||
"요소": "요소",
|
||||
"블록": "로직 부르기",
|
||||
}
|
||||
|
||||
|
||||
def visible(node: dict | None, out: list[dict]) -> list[dict]:
|
||||
"""나무에서 보이는(탄 갈래) 변수 · 블록 마디를 차례대로."""
|
||||
if not isinstance(node, dict):
|
||||
return out
|
||||
kind = node.get("꼴")
|
||||
if kind in ("변수", "블록"):
|
||||
out.append(node)
|
||||
elif kind == "조건":
|
||||
visible(node.get("조건"), out)
|
||||
for key, at in (("참", 1), ("거짓", 2)):
|
||||
if node.get("걸림") in (0, None, at):
|
||||
visible(node.get(key), out)
|
||||
else:
|
||||
for key in ("좌", "우", "안", "위", "아래"):
|
||||
visible(node.get(key), out)
|
||||
for arg in node.get("인자") or []:
|
||||
visible(arg, out)
|
||||
return out
|
||||
|
||||
|
||||
def _link(node: dict, spec: dict | None, leaf: dict | None) -> str:
|
||||
"""연결 설명 — 사람 말(열쇠 · 값 · 단위 없음)."""
|
||||
kind = node.get("갈래") or ("블록" if node.get("꼴") == "블록" else "")
|
||||
if kind == "입력":
|
||||
return "입력 — " + ("옵션에서 고름" if (spec or {}).get("고르기") else "직접 적음")
|
||||
if kind == "중간" and leaf is not None and leaf is not node:
|
||||
return _link(leaf, None, None)
|
||||
if kind == "상수":
|
||||
return f"상수 — {node.get('글')}"
|
||||
if kind == "찾기":
|
||||
cond = " · ".join(f"{k} = {v}" for k, v in (node.get("조건식") or {}).items())
|
||||
head = f"표 찾기 — {node.get('표이름') or '표'}"
|
||||
return head + (f" · 조건 {cond}" if cond else "") + f" · 칸 {node.get('칸') or ''}".rstrip()
|
||||
if kind == "요소":
|
||||
pick = node.get("고르기")
|
||||
if isinstance(pick, dict):
|
||||
return "요소 · 재료 고름 — " + " · ".join(str(v) for v in pick.values() if v)
|
||||
return f"요소 · {CAT.get(str(node.get('식') or '')[:2], '단가')} — {node.get('이름') or ''}"
|
||||
if kind == "블록":
|
||||
return f"로직 부르기 — {node.get('이름') or ''}"
|
||||
return WHAT.get(kind, "계산") + (" — 수식 영역의 식" if kind in ("중간", "결과") else "")
|
||||
|
||||
|
||||
def definitions(lines: list[dict], row: dict, rows: list[dict], unused) -> list[dict]:
|
||||
"""보이는 식의 변수 정의 목록 — `rows` = 변수 표 · `unused(열쇠)` = 어느 식에도 안 쓰임."""
|
||||
specs = {str(s.get("이름")): s for k in ("입력", "중간") for s in row.get(k) or []}
|
||||
table = {v["열쇠"]: v for v in rows}
|
||||
got: dict[str, dict] = {}
|
||||
leaf: dict[str, dict] = {}
|
||||
for line in lines:
|
||||
head, tree = line.get("결과"), line.get("이름식")
|
||||
if line.get("갈래") in ("중간", "결과") and isinstance(tree, dict):
|
||||
if tree.get("꼴") == "수": # 상수 — 연결 설명에 그 수
|
||||
leaf[str(line.get("열쇠"))] = {"갈래": "상수", "글": tree.get("글")}
|
||||
elif tree.get("갈래") in ("찾기", "요소") or tree.get("꼴") == "블록": # 한 마디 식
|
||||
leaf[str(line.get("열쇠"))] = tree
|
||||
found = ([head] if head and line.get("갈래") in ("중간", "결과") else []) + visible(
|
||||
tree, []
|
||||
)
|
||||
for node in found:
|
||||
key = str(node.get("열쇠") or "")
|
||||
if key and key not in got and node.get("갈래") in (*WHAT, "합", None):
|
||||
got[key] = node
|
||||
out: dict[tuple, dict] = {} # 같은 기호 글 = 같은 정의(겹침 합침) — 연결은 모아 보임
|
||||
for key, node in got.items():
|
||||
mine = table.get(key)
|
||||
look = mine or node
|
||||
kind = node.get("갈래") or "블록"
|
||||
if kind == "합":
|
||||
kind = "결과" if key == "결과" else "중간"
|
||||
if key.startswith("줄."): # 위 호표 줄 금액 부름
|
||||
name, link = key[2:].strip("'"), f"호표 줄 금액 — {key[2:].strip(chr(39))}"
|
||||
else:
|
||||
link = _link({**node, "갈래": kind}, specs.get(key), leaf.get(key))
|
||||
if kind == "블록" and not leaf.get(key):
|
||||
link = "로직 부르기" # 이름이 곧 그 로직 — 되풀이 안 함
|
||||
name = (look.get("이름") or key) if mine else look.get("이름") or ""
|
||||
name = node.get("칸") or name if kind == "찾기" else name
|
||||
one = {
|
||||
"열쇠": key if mine else "",
|
||||
"기호": (look.get("기호") or name) if not key.startswith("줄.") else name,
|
||||
"위": look.get("위") or "",
|
||||
"아래": look.get("아래") or "",
|
||||
"이름": name,
|
||||
"갈래": kind,
|
||||
"연결": [link],
|
||||
"고침": bool(mine),
|
||||
}
|
||||
same = out.setdefault((one["기호"], one["위"], one["아래"]), one)
|
||||
if same is not one:
|
||||
same["연결"] += [x for x in one["연결"] if x not in same["연결"]]
|
||||
if one["열쇠"] and not same["열쇠"]:
|
||||
same |= {k: one[k] for k in ("열쇠", "이름", "갈래", "고침")}
|
||||
out = list(out.values())
|
||||
seen = {d["열쇠"] for d in out}
|
||||
for v in rows: # 어느 식에도 안 쓰이는 입력 — 지울 수 있게
|
||||
if v["갈래"] == "입력" and v["열쇠"] not in seen and unused(v["열쇠"]):
|
||||
out.append(
|
||||
{
|
||||
"열쇠": v["열쇠"],
|
||||
"기호": v["기호"],
|
||||
"위": v["위"],
|
||||
"아래": v["아래"],
|
||||
"이름": v["이름"],
|
||||
"갈래": "입력",
|
||||
"연결": ["입력 — 쓰는 식 없음"],
|
||||
"고침": True,
|
||||
"안씀": True,
|
||||
}
|
||||
)
|
||||
return out
|
||||
@@ -296,7 +296,7 @@ def test_이름_기호_첨자_바꿈_전파():
|
||||
|
||||
def test_검사_까닭():
|
||||
cases = {
|
||||
"없는 이름": ("중간:환산거리", "환산계수 × 없는값", "모르는 이름"),
|
||||
"없는 이름": ("중간:환산거리", "환산계수 × 없는값", "모르는 기호"),
|
||||
"괄호": ("중간:환산거리", "(환산계수 × L", "없음"),
|
||||
"아래 줄": ("중간:무게", "일운반회수 × 2", "아래 줄"),
|
||||
"순환": ("중간:무게", "무게 + 1", "자기 자신"),
|
||||
|
||||
@@ -32,7 +32,6 @@ def test_부품_상자만_날_단추_입력_없음():
|
||||
# 수식 칩 = 글 속 부품(span role) · 단추 · 입력 · 고르기는 부품 상자 것
|
||||
page = _text(PAGE[0]) + _text(PAGE[1])
|
||||
for part in (
|
||||
"createIconButton",
|
||||
"createButton",
|
||||
"createToolbar",
|
||||
"createFormulaCell",
|
||||
@@ -84,15 +83,17 @@ def test_수식_탭이_첫_탭_단가산출_상세는_그대로():
|
||||
assert "equation.canLeave()" in lab # 저장 전 떠나면 버림 물음
|
||||
|
||||
|
||||
def test_아이콘_줄은_고른_줄에만_고침은_관리자만():
|
||||
def test_식_바로_고침은_관리자만():
|
||||
# 800-3 — 줄을 눌러 여는 아래 편집 · 아이콘 줄을 걷고 식 칸을 그 자리에서 고침
|
||||
line = _text(PARTS[1])
|
||||
assert "o.selected === id ? o.slot?.(line, id) : null" in line # 고른 줄에만 식 칸 · 아이콘 줄
|
||||
assert "o.selected" not in line and "o.slot" not in line and "onSelect" not in line
|
||||
assert "o.editing === id && o.editor" in line # 이름 식 칸 자리에 식 칸
|
||||
page = _text(PAGE[0])
|
||||
for icon in ('"frac"', '"paren"', '"sub"', '"sup"', '"percent"'):
|
||||
assert icon in page, icon # 분수 · 괄호 · 첨자 · %
|
||||
for icon in ('"frac"', '"paren"', '"sub"', '"sup"'):
|
||||
assert icon not in page, icon # 아이콘 줄 걷음
|
||||
assert 'label: t("Add")' in page # 변수 더함
|
||||
assert "opt.admin && !opt.blocked()" in page # 관리자 + 시트 쪽 저장 안 한 고침 없을 때만
|
||||
assert "onSelect: edit" in page and "onVar: edit" in page and "slot: edit" in page
|
||||
assert "onEdit: edit" in page and "editor: edit" in page
|
||||
assert "createFormulaCell" in page and "../A00_Common/sheet_formula_cell" in page
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""PLAN 800-2 · 800-3 — 수식 탭 변수 정의 영역 · 바로 고침 · 새 기호 연결 (서버 몫 · 임시 쓰기 없음)."""
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path[:0] = [str(ROOT), str(ROOT / "resources/master_data/scripts")]
|
||||
|
||||
from M01_MasterData import M01_MasterData_Equation as eq # noqa: E402
|
||||
from M01_MasterData import M01_MasterData_Store as store # noqa: E402
|
||||
|
||||
KEY = "GC000211"
|
||||
OFF = {"폼타이": "아님", "측압": "3t/㎡"}
|
||||
ON = {"폼타이": "사용", "측압": "4t/㎡"}
|
||||
|
||||
|
||||
def _defs(inputs):
|
||||
return eq.read(KEY, inputs)["수식"]["정의"]
|
||||
|
||||
|
||||
def _text(defs):
|
||||
return json.dumps(defs, ensure_ascii=False)
|
||||
|
||||
|
||||
def test_defs_merge_and_clean():
|
||||
defs = _defs(OFF)
|
||||
signs = [(d["기호"], d["위"], d["아래"]) for d in defs]
|
||||
assert len(signs) == len(set(signs)), "같은 변수는 한 번만"
|
||||
assert not re.search(r"[A-Z]{2}\d{6}", _text(defs)), "DB 열쇠 글 0"
|
||||
assert all(set(d) <= {"열쇠", "기호", "위", "아래", "이름", "갈래", "연결", "고침", "안씀"} for d in defs)
|
||||
by = {d["기호"]: d for d in defs}
|
||||
assert len(by["보통인부"]["연결"]) >= 3 # 중간 줄 + 표 둘 — 한 줄로 합침
|
||||
assert by["사용횟수"]["연결"] == ["입력 — 옵션에서 고름"]
|
||||
|
||||
|
||||
def test_unpicked_branches_hidden():
|
||||
off, on = _text(_defs(OFF)), _text(_defs(ON))
|
||||
for col in ("4t/㎡", "5t/㎡", "6t/㎡", "폼타이 소요량"):
|
||||
assert col not in off
|
||||
assert "칸 4t/㎡" in on and "칸 3t/㎡" not in on and "칸 5t/㎡" not in on
|
||||
assert "측압" in on and "측압" not in off # 폼타이 아님이면 측압 갈래도 안 탐
|
||||
|
||||
|
||||
def test_live_edit_preview_changes_total():
|
||||
spot = {"할일": "식", "자리": "호표:0", "식": "형틀목공 × 2"}
|
||||
got = eq.preview(KEY, [spot], OFF)
|
||||
assert got["되나"] and got["후"]["계"] > got["전"]["계"]
|
||||
|
||||
|
||||
def test_unknown_symbol_asks_link_then_links():
|
||||
spot = {"할일": "식", "자리": "호표:0", "식": "형틀목공 × 2 + Z"}
|
||||
got = eq.preview(KEY, [spot], OFF)
|
||||
assert not got["되나"] and "모르는 기호 「Z」" in got["까닭"][0]
|
||||
for link in (
|
||||
{"갈래": "상수", "값": "0"},
|
||||
{"갈래": "입력", "기본값": "0"},
|
||||
{"갈래": "표", "표": "QC000431", "칸": "형틀목공", "조건": {"유형": "유형"}},
|
||||
{"갈래": "요소", "요소": "LB000007"},
|
||||
):
|
||||
add = {"할일": "연결", "열쇠": "Z", "이름": "새값", **link}
|
||||
got = eq.preview(KEY, [add, spot], OFF)
|
||||
assert got["되나"], (link, got["까닭"])
|
||||
assert any(d["기호"] == "Z" for d in got["수식"]["정의"])
|
||||
|
||||
|
||||
def test_link_bad_input_blocked():
|
||||
bad = [
|
||||
{"갈래": "표", "표": "QC000431", "칸": "없는칸", "조건": {"유형": "유형"}},
|
||||
{"갈래": "표", "표": "QC000431", "칸": "형틀목공", "조건": {}},
|
||||
{"갈래": "요소", "요소": "GC000211"},
|
||||
{"갈래": "상수", "값": "abc"},
|
||||
]
|
||||
for link in bad:
|
||||
got = eq.preview(KEY, [{"할일": "연결", "열쇠": "Z", **link}], OFF)
|
||||
assert not got["되나"], link
|
||||
|
||||
|
||||
def test_drop_guard_and_unused_input():
|
||||
got = eq.preview(KEY, [{"할일": "지움", "열쇠": "사용횟수"}], OFF)
|
||||
assert not got["되나"] and "쓰는 곳" in got["까닭"][0]
|
||||
add = {"할일": "연결", "열쇠": "Y", "갈래": "입력"}
|
||||
got = eq.preview(KEY, [add], OFF)
|
||||
unused = [d for d in got["수식"]["정의"] if d["기호"] == "Y"]
|
||||
assert unused and unused[0].get("안씀")
|
||||
assert eq.preview(KEY, [add, {"할일": "지움", "열쇠": "Y"}], OFF)["되나"]
|
||||
|
||||
|
||||
def test_candidates():
|
||||
whole = store.loaded()[1]
|
||||
got = eq.links({"갈래": "표", "검색": "합판거푸집 폼타이", "분류": ""})
|
||||
assert got["후보"] and "조건" in got["후보"][0]
|
||||
assert eq.links({"갈래": "요소", "검색": "형틀목공", "분류": "인력"})["후보"]
|
||||
assert eq.links({"갈래": "로직", "검색": "합판거푸집", "분류": ""})["후보"]
|
||||
assert whole is not None
|
||||
|
||||
|
||||
def test_groups_add_up_to_total():
|
||||
"""800-4 — 묶음(블록 · 자원 · 덧줄) 소계 합 + 끝수 = 합계(비목마다) · 한 묶음 소계 = 노 + 재 + 경."""
|
||||
for key in ("GC001148", "GC000211"):
|
||||
eqn = eq.read(key, {})["수식"]
|
||||
sub, total = eqn["소계"], eqn["합계"]
|
||||
for k in ("노무비", "재료비", "경비"):
|
||||
got = sum(float(r[k]) for r in sub["줄"]) + float(sub["끝수"][k])
|
||||
assert abs(got - float(total[k])) < 1e-6, (key, k)
|
||||
assert all(
|
||||
abs(float(r["계"]) - sum(float(r[k]) for k in ("노무비", "재료비", "경비"))) < 1e-6
|
||||
for r in sub["줄"]
|
||||
)
|
||||
|
||||
|
||||
def test_screen_source_groups_and_inline_edit():
|
||||
ui = ROOT / "ui_template"
|
||||
sum_ts = (ui / "ui_template_equation_sum.ts").read_text(encoding="utf-8")
|
||||
line_ts = (ui / "ui_template_equation_line.ts").read_text(encoding="utf-8")
|
||||
page = (ROOT / "M01_MasterData/M01_MasterData_UI_LogicLab_Equation.ts").read_text(encoding="utf-8")
|
||||
assert "ORDER" in sum_ts and "ui-eq__grp" in sum_ts # 가 · 나 · 다 묶음
|
||||
assert "o.after?.(line)" in line_ts and "after:" in page # 블록 식 줄 바로 아래
|
||||
assert "block-key" not in line_ts # 줄 곁 DB 열쇠 글 없음
|
||||
assert "ui-eq__solve" not in line_ts and "ui-eq__step" not in line_ts # 풀이 · 줄 번호 걷음
|
||||
@@ -158,37 +158,17 @@
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
/* ── 줄 ── */
|
||||
/* ── 줄(800-2 — 구분선 · 번호 · 풀이 없음 · 식이 차례로 이어짐) ── */
|
||||
.ui-eq__line {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-8);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding: var(--spacing-4) 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ui-eq__line[data-selected] {
|
||||
box-shadow: inset 3px 0 0 var(--color-accent);
|
||||
background: var(--color-accent-soft);
|
||||
}
|
||||
|
||||
.ui-eq__line.is-pick > .ui-eq__main {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ui-eq__main {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(12rem, 22rem);
|
||||
gap: var(--spacing-12);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.ui-eq__step {
|
||||
align-self: flex-start;
|
||||
min-width: 1.4em;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 네 칸 상자 — 결과명 = 이름 식 = 값 식 = 결과 값 */
|
||||
@@ -226,51 +206,24 @@
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
/* 변수 풀이 */
|
||||
.ui-eq__solve {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
/* 바로 고침 — 이름 식 칸을 누르면 그 자리에서 식 칸 */
|
||||
.ui-eq__name.is-edit {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.ui-eq__name.is-edit:hover {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.ui-eq__name.is-open {
|
||||
flex: 1 1 24rem;
|
||||
align-items: flex-start;
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.ui-eq__name.is-open > * {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
font-size: var(--text-caption);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.ui-eq__solve-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 0 var(--spacing-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ui-eq__solve-row.is-pick {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ui-eq__solve-row.is-pick:hover {
|
||||
background: var(--color-accent-soft);
|
||||
}
|
||||
|
||||
.ui-eq__solve-name,
|
||||
.ui-eq__solve-val {
|
||||
max-width: 12em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ui-eq__solve-val {
|
||||
color: var(--color-text-body);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.ui-eq__solve-src {
|
||||
color: var(--color-text-muted);
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 하위 로직 블록 — 접힘 */
|
||||
@@ -300,11 +253,6 @@
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.ui-eq__block-key {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.ui-eq__block-body {
|
||||
margin-top: var(--spacing-4);
|
||||
}
|
||||
@@ -313,12 +261,6 @@
|
||||
padding: var(--spacing-4);
|
||||
}
|
||||
|
||||
@container (max-width: 640px) {
|
||||
.ui-eq__main {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
:is(.ui-eq__block-body, .ui-eq__bad)[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
@@ -411,3 +353,125 @@
|
||||
.ui-eq__sum-row[data-eq-sum-kind="합계"] {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
/* ── 변수 정의 영역(800-2) — 오른쪽 한 덩어리 ── */
|
||||
.ui-eq__defs {
|
||||
position: sticky;
|
||||
top: var(--spacing-8);
|
||||
align-self: start;
|
||||
max-height: calc(100vh - var(--spacing-24));
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
min-width: 0;
|
||||
padding: var(--spacing-8);
|
||||
border-left: 1px solid var(--color-border);
|
||||
font-size: var(--text-body-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.ui-eq__defs-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.ui-eq__def {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-4);
|
||||
border-radius: var(--radius-sm);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ui-eq__def-top {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ui-eq__def.is-pick > .ui-eq__def-top {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ui-eq__def.is-pick > .ui-eq__def-top:hover,
|
||||
.ui-eq__def[data-flash],
|
||||
.ui-eq__def[data-editing] {
|
||||
background: var(--color-accent-soft);
|
||||
}
|
||||
|
||||
.ui-eq__def-main {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0 var(--spacing-4);
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
.ui-eq__def-name {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.ui-eq__def-tag {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.ui-eq__def-link {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.ui-eq__def-empty {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* ── 기준 단가산출별 묶음(800-4 · 요구 661) — 가. 이름 → 노 · 재 · 경 → 소 계 ── */
|
||||
.ui-eq__grp {
|
||||
font-size: var(--text-body-sm);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: var(--spacing-8) 0 var(--spacing-4);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.ui-eq__grp-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
padding: 0 var(--spacing-8);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.ui-eq__grp-line {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--spacing-4);
|
||||
padding: 0 var(--spacing-8) 0 var(--spacing-24);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.ui-eq__grp-key {
|
||||
min-width: 4.5em;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.ui-eq__grp-total .ui-eq__sum-cell::before {
|
||||
content: attr(data-eq-sum-col) " ";
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.ui-eq__grp-total {
|
||||
border-bottom: 0;
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface EqNode {
|
||||
표?: string;
|
||||
표이름?: string;
|
||||
행?: Record<string, unknown>;
|
||||
조건식?: Record<string, string>;
|
||||
좌?: EqNode;
|
||||
우?: EqNode;
|
||||
안?: EqNode;
|
||||
@@ -89,6 +90,24 @@ export interface EqTree {
|
||||
소계?: { 줄: EqSumRow[]; 끝수: Record<string, unknown>; 끝수까닭?: string };
|
||||
/** M02 SK — 셈 답이 안 서서 바탕 줄 합계를 보일 때의 까닭 */
|
||||
답까닭?: string;
|
||||
/** 800-2 — 오른쪽 변수 정의 영역(고른 옵션 갈래만 · 같은 기호 한 줄 · 값 · 단위 · DB 열쇠 없음) */
|
||||
정의?: EqDef[];
|
||||
}
|
||||
|
||||
/** 변수 정의 한 줄 — 기호 : 이름 — 연결(사람 말 · 여럿이면 줄 바꿈) */
|
||||
export interface EqDef {
|
||||
/** 변수 열쇠(입력 · 중간 변수만 · 찾기 · 요소는 빈 글) — 화면 글에는 안 나옴 */
|
||||
열쇠: string;
|
||||
기호: string;
|
||||
위: string;
|
||||
아래: string;
|
||||
이름: string;
|
||||
갈래: string;
|
||||
연결: string[];
|
||||
/** 이름 · 기호 · 첨자 고침 · 지움이 되는 변수 */
|
||||
고침: boolean;
|
||||
/** 어느 식에도 안 쓰이는 입력(지울 수 있음) */
|
||||
안씀?: boolean;
|
||||
}
|
||||
|
||||
export interface EqVarRow {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/* =============================================================================
|
||||
* ui_template_equation_defs.ts
|
||||
* 부품 「수식」 넷째 파일(800-2 · 요구 660-2 · 3 · 6) — 오른쪽 「변수 정의 영역」.
|
||||
* 한 덩어리 목록 · 같은 기호는 한 줄(서버가 합침) · 줄 = 기호 : 국문 이름 — 연결 설명(입력 · 표 찾기 · 요소 · 로직 · 계산을 사람 말로)
|
||||
* 값 · 단위 · DB 열쇠 글은 안 그림(값은 왼쪽 값 식에 있음) · 고른 옵션 갈래의 변수만(서버 `정의`)
|
||||
* 상태 = 고침 열린 줄 `data-editing`(줄 아래 `editor` 칸) · 연결 안 한 새 기호 줄 `data-draft`(`drafts` 요소 — 부르는 쪽이 만듦) · 눌러 보인 줄 `data-flash`
|
||||
* 쓰는 법 = `createEqDefs(defs, { editing, onEdit, editor, drafts, head })` → 요소 · `flashEqDef(root, node)` = 식의 변수를 눌렀을 때 그 정의 줄로 이동
|
||||
* ========================================================================== */
|
||||
|
||||
import { symbolOf, type EqDef, type EqNode } from "./ui_template_equation";
|
||||
|
||||
export interface EqDefsOptions {
|
||||
/** 고침이 열린 정의의 기호 글(`defSign`) */
|
||||
editing?: string | null;
|
||||
/** 정의 줄을 누름(없으면 고칠 수 없음 — 고침 가능한 줄만 눌림) */
|
||||
onEdit?: (def: EqDef) => void;
|
||||
/** 고침이 열린 줄 아래 칸 */
|
||||
editor?: (def: EqDef) => HTMLElement;
|
||||
/** 연결을 지정할 새 기호 줄들 */
|
||||
drafts?: HTMLElement[];
|
||||
/** 머리 오른쪽 단추 자리 */
|
||||
head?: HTMLElement[];
|
||||
}
|
||||
|
||||
const node = (tag: "div" | "span", className: string, text = ""): HTMLElement => {
|
||||
const out = document.createElement(tag);
|
||||
out.className = className;
|
||||
if (text) out.textContent = text;
|
||||
return out;
|
||||
};
|
||||
|
||||
/** 정의의 기호 글(기호 · 아래 · 위) — 줄 이름표 · 식의 변수와 맞대는 열쇠 */
|
||||
export const defSign = (d: Pick<EqDef, "기호" | "위" | "아래">): string =>
|
||||
`${d.기호}${d.아래 ? `_${d.아래}` : ""}${d.위 ? `^${d.위}` : ""}`;
|
||||
|
||||
/** 식 변수 마디의 기호 글 — `defSign` 과 같은 꼴 */
|
||||
export const nodeSign = (n: Pick<EqNode, "기호" | "열쇠" | "위" | "아래">): string =>
|
||||
defSign({
|
||||
기호: n.기호 || n.열쇠 || "",
|
||||
위: typeof n.위 === "string" ? n.위 : "",
|
||||
아래: typeof n.아래 === "string" ? n.아래 : "",
|
||||
});
|
||||
|
||||
function defRow(d: EqDef, o: EqDefsOptions): HTMLElement {
|
||||
const sign = defSign(d);
|
||||
const row = node("div", "ui-eq__def");
|
||||
row.dataset.eqDef = sign;
|
||||
row.dataset.kind = d.갈래;
|
||||
const main = node("div", "ui-eq__def-main");
|
||||
main.append(symbolOf({ 열쇠: d.열쇠, 기호: d.기호, 위: d.위, 아래: d.아래 }));
|
||||
if (d.이름 && d.이름 !== d.기호)
|
||||
main.append(node("span", "ui-eq__op", ":"), node("span", "ui-eq__def-name", d.이름));
|
||||
if (d.안씀) main.append(node("span", "ui-eq__def-tag", "쓰는 식 없음"));
|
||||
const links = node("div", "ui-eq__def-links");
|
||||
for (const text of d.연결) links.append(node("div", "ui-eq__def-link", text));
|
||||
const top = node("div", "ui-eq__def-top", "");
|
||||
top.append(main, links);
|
||||
if (o.onEdit && d.고침) {
|
||||
row.classList.add("is-pick");
|
||||
top.title = "눌러서 이름 · 기호 · 첨자 고침 · 지움";
|
||||
top.addEventListener("click", () => o.onEdit?.(d));
|
||||
}
|
||||
row.append(top);
|
||||
if (o.editing === sign && o.editor) {
|
||||
row.dataset.editing = "1";
|
||||
row.append(o.editor(d));
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
export function createEqDefs(defs: EqDef[], o: EqDefsOptions = {}): HTMLElement {
|
||||
const root = node("div", "ui-eq__defs");
|
||||
root.dataset.eqDefs = "";
|
||||
const head = node("div", "ui-eq__defs-head");
|
||||
head.append(node("span", "ui-eq__defs-title", "변수 정의"), ...(o.head ?? []));
|
||||
root.append(head);
|
||||
for (const draft of o.drafts ?? []) root.append(draft); // 새 기호는 맨 위 — 식을 고치는 곳 옆에서 바로 보이게
|
||||
for (const d of defs) root.append(defRow(d, o));
|
||||
if (!defs.length && !o.drafts?.length) root.append(node("div", "ui-eq__def-empty", "—"));
|
||||
return root;
|
||||
}
|
||||
|
||||
/** 식의 변수를 눌렀을 때 — 그 정의 줄로 이동 · 잠깐 표시 */
|
||||
export function flashEqDef(root: HTMLElement, n: EqNode): void {
|
||||
const sign = nodeSign(n);
|
||||
const row = [...root.querySelectorAll<HTMLElement>("[data-eq-def]")].find(
|
||||
(r) => r.dataset.eqDef === sign,
|
||||
);
|
||||
if (!row) return;
|
||||
row.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
row.dataset.flash = "1";
|
||||
window.setTimeout(() => delete row.dataset.flash, 1200);
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
/* =============================================================================
|
||||
* ui_template_equation_line.ts
|
||||
* 부품 「수식」 둘째 파일(796-2) — 줄 · 변수 풀이 · 하위 로직 블록 · 여러 줄 이음. 식 그림 = `ui_template_equation.ts`.
|
||||
* 줄 = 번호 · 결과 변수명 = 이름 식 = 값 식 = 결과 값(단위) — 네 칸(PPT 꼴) · 오른쪽 변수 풀이(기호 : 이름 = 값 단위 · 출처)
|
||||
* 부품 「수식」 둘째 파일(796-2 · 800-2) — 수식 영역 줄 · 하위 로직 블록 · 여러 줄 이음. 식 그림 = `ui_template_equation.ts`.
|
||||
* 줄 = 결과 변수명 = 이름 식 = 값 식 = 결과 값(단위) — 네 칸(PPT 꼴) · 줄 번호 · 구분선 · 줄마다 풀이 없음(변수 정의 = `ui_template_equation_defs.ts`)
|
||||
* 값 식이 결과와 같으면 값 식 칸 생략 · 식을 못 읽은 줄 = 원문 + 까닭(붉은 글)
|
||||
* 이음 = 여러 줄 위에서 아래로(`createEqChain`) · 중간 계산이 한눈에 · 하위 로직 부르기 = 줄 아래 접힘 블록(노 · 재 · 경 · 계 소계 · 펼치면 그 로직 줄 이음)
|
||||
* 크기 = 글 `--text-body` · 풀이 `--text-caption` · 접힘 단추 22
|
||||
* 상태 = 고른 줄 `data-selected`(왼쪽 띠 · 식 칸 자리 `slot` 이 줄 아래에 열림) · 펼친 블록 `data-open`
|
||||
* 쓰는 법 = `createEqChain(tree.줄, { path: "", open: new Set(), selected, onSelect, onVar, slot })`
|
||||
* 대신한 옛 변종 = 시트 줄(이름 | 식 | 값 한 줄 · 분수 · 풀이 없음 · 799-2 에 걷음)
|
||||
* 이음 = 여러 줄 위에서 아래로(`createEqChain`) · 하위 로직 부르기 = 줄 아래 접힘 블록(노 · 재 · 경 · 계 소계 · 펼치면 그 로직 줄 이음)
|
||||
* 크기 = 글 `--text-body` · 접힘 단추 22
|
||||
* 상태 = 바로 고침 — 이름 식 칸을 누르면 그 자리가 식 칸으로 바뀜(`onEdit` → 부르는 쪽이 `editing` 을 정해 다시 그림 · `editor` 가 칸을 줌) · 펼친 블록 `data-open`
|
||||
* 쓰는 법 = `createEqChain(tree.줄, { path: "", open: new Set(), onVar, editing, onEdit, editor })`
|
||||
* 대신한 옛 변종 = 시트 줄(이름 | 식 | 값 한 줄 · 분수 · 풀이 없음 · 799-2 에 걷음) · 줄 눌러 여는 아래 편집 칸(800-3 에 걷음)
|
||||
* ========================================================================== */
|
||||
|
||||
import { createBadge } from "./ui_template_badge";
|
||||
@@ -27,18 +27,18 @@ export interface EqLineOptions {
|
||||
path: string;
|
||||
/** 펼친 블록 이름표 모음 — 다시 그려도 유지 */
|
||||
open: Set<string>;
|
||||
/** 고른 줄 이름표(`lineId`) */
|
||||
selected?: string | null;
|
||||
/** 줄을 누름(없으면 안 눌림) */
|
||||
onSelect?: (line: EqLine, id: string) => void;
|
||||
/** 변수 누름(없으면 눌리지 않음) */
|
||||
onVar?: (node: EqNode) => void;
|
||||
/** 고른 줄 아래에 열 칸(식 칸 · 아이콘 줄) */
|
||||
slot?: (line: EqLine, id: string) => HTMLElement | null;
|
||||
/** 바로 고치는 줄 이름표(`lineId`) — 이 줄의 이름 식 칸 자리에 `editor` 칸이 들어감 */
|
||||
editing?: string | null;
|
||||
/** 이름 식 칸을 누름(없으면 고칠 수 없음) */
|
||||
onEdit?: (line: EqLine, id: string) => void;
|
||||
/** 고치는 줄의 식 칸 */
|
||||
editor?: (line: EqLine, id: string) => HTMLElement;
|
||||
/** 줄 바로 아래에 끼울 것(하위 로직 블록 소계 묶음 · 800-4) */
|
||||
after?: (line: EqLine) => HTMLElement | null;
|
||||
/** 결과 줄 값 뒤 단위 */
|
||||
resultUnit?: string;
|
||||
/** 줄 번호 1 부터 보임 */
|
||||
numbered?: boolean;
|
||||
}
|
||||
|
||||
const BUNDLE: [string, string][] = [
|
||||
@@ -67,52 +67,6 @@ export const lineId = (line: EqLine): string =>
|
||||
? `${line.갈래}:${line.차례 ?? 0}`
|
||||
: `${line.갈래}:${line.열쇠}`;
|
||||
|
||||
/** 식 안 변수(찾기 · 입력 · 중간 · 요소) — 열쇠마다 하나 · 블록 속은 안 들어감 */
|
||||
export function varsOf(node: EqNode | undefined, out = new Map<string, EqNode>()): EqNode[] {
|
||||
if (!node) return [];
|
||||
if (node.꼴 === "변수" && ["입력", "중간", "찾기", "요소"].includes(node.갈래 ?? "")) {
|
||||
if (!out.has(node.열쇠 ?? "")) out.set(node.열쇠 ?? "", node);
|
||||
}
|
||||
for (const k of ["좌", "우", "안", "조건", "참", "거짓"] as const) varsOf(node[k], out);
|
||||
for (const k of ["위", "아래"] as const) {
|
||||
const child = node[k];
|
||||
if (typeof child === "object") varsOf(child, out);
|
||||
}
|
||||
(node.인자 ?? []).forEach((a) => varsOf(a, out));
|
||||
return [...out.values()];
|
||||
}
|
||||
|
||||
const SOURCE: Record<string, string> = { 입력: "입력", 중간: "중간 줄", 요소: "요소" };
|
||||
|
||||
function solveRow(node: EqNode, o: EqLineOptions): HTMLElement {
|
||||
const value = eqText(node.값);
|
||||
const where =
|
||||
node.갈래 === "찾기"
|
||||
? [node.표, ...Object.entries(node.행 ?? {}).map(([k, v]) => `${k}=${String(v)}`)]
|
||||
.filter(Boolean)
|
||||
.join(" · ")
|
||||
: (SOURCE[node.갈래 ?? ""] ?? "");
|
||||
const name = span("ui-eq__solve-name", node.이름 || node.열쇠 || "");
|
||||
name.title = name.textContent ?? "";
|
||||
const val = span("ui-eq__solve-val", value ? `${value}${node.단위 ? ` ${node.단위}` : ""}` : "—");
|
||||
val.title = val.textContent ?? "";
|
||||
const row = div(
|
||||
"ui-eq__solve-row",
|
||||
symbolOf(node),
|
||||
span("ui-eq__op", ":"),
|
||||
name,
|
||||
span("ui-eq__op", "="),
|
||||
val,
|
||||
...(where ? [span("ui-eq__solve-src", where)] : []),
|
||||
);
|
||||
row.dataset.eqSolve = node.열쇠 ?? "";
|
||||
if (o.onVar && node.갈래 !== "찾기" && node.갈래 !== "요소") {
|
||||
row.classList.add("is-pick");
|
||||
row.addEventListener("click", () => o.onVar?.(node));
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
function bundle(node: EqNode): HTMLElement {
|
||||
const box = span("ui-eq__bundle");
|
||||
const got = node.묶음;
|
||||
@@ -149,7 +103,6 @@ function blockPanel(node: EqNode, at: number, o: EqLineOptions): HTMLElement {
|
||||
path: id,
|
||||
open: o.open,
|
||||
resultUnit: node.결과단위 ?? node.나무.결과단위,
|
||||
numbered: false,
|
||||
}),
|
||||
);
|
||||
} else o.open.delete(id);
|
||||
@@ -157,10 +110,7 @@ function blockPanel(node: EqNode, at: number, o: EqLineOptions): HTMLElement {
|
||||
body.hidden = !on;
|
||||
};
|
||||
body.hidden = true;
|
||||
box.append(
|
||||
div("ui-eq__block-row", head, bundle(node), span("ui-eq__block-key", node.열쇠 ?? "")),
|
||||
body,
|
||||
);
|
||||
box.append(div("ui-eq__block-row", head, bundle(node)), body);
|
||||
if (o.open.has(id)) toggle(true);
|
||||
return box;
|
||||
}
|
||||
@@ -183,7 +133,7 @@ function markOf(line: EqLine): HTMLElement | null {
|
||||
}
|
||||
|
||||
/** 줄 하나 */
|
||||
export function createEqLine(line: EqLine, index: number, o: EqLineOptions): HTMLElement {
|
||||
export function createEqLine(line: EqLine, o: EqLineOptions): HTMLElement {
|
||||
const id = lineId(line);
|
||||
const root = div("ui-eq__line");
|
||||
root.dataset.eqLine = id;
|
||||
@@ -201,7 +151,6 @@ export function createEqLine(line: EqLine, index: number, o: EqLineOptions): HTM
|
||||
},
|
||||
};
|
||||
const body = div("ui-eq__body");
|
||||
if (o.numbered !== false) body.append(span("ui-eq__step", String(index + 1)));
|
||||
const head = line.결과;
|
||||
if (head)
|
||||
body.append(
|
||||
@@ -225,10 +174,19 @@ export function createEqLine(line: EqLine, index: number, o: EqLineOptions): HTM
|
||||
line.글 || "—",
|
||||
...(resUnit ? [span("ui-eq__unit", resUnit)] : []),
|
||||
);
|
||||
let vars: EqNode[] = [];
|
||||
if (line.이름식) {
|
||||
vars = varsOf(line.이름식);
|
||||
const name = span("ui-eq__box ui-eq__name", renderEq(line.이름식, hand));
|
||||
const name = span("ui-eq__box ui-eq__name");
|
||||
if (o.editing === id && o.editor) {
|
||||
name.classList.add("is-open");
|
||||
name.append(o.editor(line, id));
|
||||
} else {
|
||||
name.append(renderEq(line.이름식, hand));
|
||||
if (o.onEdit) {
|
||||
name.classList.add("is-edit");
|
||||
name.title = "눌러서 바로 고침";
|
||||
name.addEventListener("click", () => o.onEdit?.(line, id));
|
||||
}
|
||||
}
|
||||
body.append(span("ui-eq__op", "="), name);
|
||||
const same =
|
||||
plainEq(line.값식) === (line.글 ?? "") || plainEq(line.값식) === plainEq(line.이름식);
|
||||
@@ -245,21 +203,14 @@ export function createEqLine(line: EqLine, index: number, o: EqLineOptions): HTM
|
||||
const mark = markOf(line);
|
||||
if (mark) body.append(mark);
|
||||
if (line.뺌) root.dataset.removed = "1";
|
||||
const solve = div("ui-eq__solve", ...vars.map((v) => solveRow(v, o)));
|
||||
const main = div("ui-eq__main", body, solve);
|
||||
if (o.onSelect) {
|
||||
root.classList.add("is-pick");
|
||||
main.addEventListener("click", () => o.onSelect?.(line, id));
|
||||
}
|
||||
if (o.selected === id) root.dataset.selected = "1";
|
||||
root.append(main);
|
||||
root.append(div("ui-eq__main", body));
|
||||
blocks.forEach((b, i) => root.append(blockPanel(b, i + 1, o)));
|
||||
const slot = o.selected === id ? o.slot?.(line, id) : null;
|
||||
if (slot) root.append(slot);
|
||||
const extra = o.after?.(line);
|
||||
if (extra) root.append(extra);
|
||||
return root;
|
||||
}
|
||||
|
||||
/** 여러 줄 위에서 아래로 이음 */
|
||||
export function createEqChain(lines: EqLine[], o: EqLineOptions): HTMLElement {
|
||||
return div("ui-eq", ...lines.map((l, i) => createEqLine(l, i, o)));
|
||||
return div("ui-eq", ...lines.map((l) => createEqLine(l, o)));
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/* =============================================================================
|
||||
* ui_template_equation_sum.ts
|
||||
* 부품 「수식」 셋째 파일(799-1 · 796-2c · 요구 655-1 · 요구서_655 2 장 4) — 단가산출 소계 · 합계(보이기만 · 금액 셈 그대로).
|
||||
* 표 = 줄(하위 로직 블록마다 한 줄 · 자기 자원 · 덧줄) × 노무비 · 재료비 · 경비 · 계 → 합계(엔진 비목 합) → 끝수(합계 − 줄 합)
|
||||
* 값 = 서버 `소계`(M01 `master_equation.subtotals` · M02 `M02_Sangeun_Equation_Full` = SK 셈 답) · 끝수가 0 이면 줄을 안 그림 · 소계가 없으면(결과 로직) 아무것도 안 그림
|
||||
* M02 SK 줄 = 상태 알약(더함 · 덮음 · 뺌) · 덮음 갈래 · 원래 금액 · 수량 · 더한 기초는 접힘 수식 블록 · 뺌 = 흐리게 · 금액 0
|
||||
* 쓰는 법 = `createEqSum(tree, open?, hooks?)` → 요소 | null — 줄 이음(`createEqChain`) 아래에 붙임 · `open` = 펼친 블록 이름표 모음 ·
|
||||
* 부품 「수식」 셋째 파일(799-1 · 796-2c · 800-4 · 요구 655-1 · 661 · 요구서_655 2 장 4) — 단가산출 소계 · 합계(보이기만 · 금액 셈 그대로).
|
||||
* 묶음 = 기준 단가산출마다(하위 로직 블록 · 자기 자원 · 덧줄) 「가. 이름」 머리 → 노무비 · 재료비 · 경비 줄 → 소 계(노 · 재 · 경 · 계 칸)
|
||||
* 맨 끝 「소 계」 묶음 = 묶음 소계를 더한 식(노무비 : 2,299.4 + 5,162 = 7,461.4) → 끝수(합계 − 묶음 합 · 0 이면 줄 없음) → 합계(엔진 비목 합)
|
||||
* 값 = 서버 `소계`(M01 `master_equation.subtotals` · M02 `M02_Sangeun_Equation_Full` = SK 셈 답) · 소계가 없으면(결과 로직) 아무것도 안 그림
|
||||
* M02 SK 묶음 = 상태 알약(더함 · 덮음 · 뺌) · 덮음 갈래 · 원래 금액 · 수량 · 더한 기초는 접힘 수식 블록 · 뺌 = 흐리게 · 금액 0
|
||||
* 자리 = 하위 로직 블록 묶음은 그 블록 식 줄 바로 아래(`inline` · 줄 열쇠 = 블록 이름 · `createEqChain` 의 `after`) · 나머지(자기 자원 · 덧줄 · 더한 기초) · 맨 끝 소 계 · 끝수 · 합계는 `tail`
|
||||
* 쓰는 법 = `createEqSum(tree, open?, hooks?, lineKeys?)` → { inline, tail } | null — `lineKeys` = 수식 영역 호표 줄 열쇠들 · `open` = 펼친 블록 이름표 모음 ·
|
||||
* `hooks` = 페이지가 더한 기초 줄 머리 단추(`head`) · 접힘 속(`body`)을 바꿔 끼움(796-2e 연결 끊기)
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -14,8 +16,14 @@ import { eqText, type EqSumHooks, type EqSumRow, type EqTree } from "./ui_templa
|
||||
import { createEqChain } from "./ui_template_equation_line";
|
||||
|
||||
const COLS = ["노무비", "재료비", "경비", "계"] as const;
|
||||
const PARTS = ["노무비", "재료비", "경비"] as const;
|
||||
type Col = (typeof COLS)[number];
|
||||
const KIND: Record<string, string> = { 자원: "자기 자원", 덧줄: "덧줄" };
|
||||
const TONE: Record<string, BadgeTone> = { 더함: "success", 덮음: "warning", 뺌: "neutral" };
|
||||
const ORDER = "가나다라마바사아자차카타파하";
|
||||
|
||||
/** 묶음 번호 — 가 · 나 · 다 … (열네 개를 넘으면 숫자) */
|
||||
const mark = (at: number): string => (at < ORDER.length ? (ORDER[at] ?? "") : String(at + 1));
|
||||
|
||||
const node = (tag: "div" | "span", className: string, text = ""): HTMLElement => {
|
||||
const out = document.createElement(tag);
|
||||
@@ -24,7 +32,12 @@ const node = (tag: "div" | "span", className: string, text = ""): HTMLElement =>
|
||||
return out;
|
||||
};
|
||||
|
||||
function cells(line: HTMLElement, get: (k: (typeof COLS)[number]) => unknown): void {
|
||||
const num = (v: unknown): number => {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
};
|
||||
|
||||
function cells(line: HTMLElement, get: (k: Col) => unknown): void {
|
||||
for (const k of COLS) {
|
||||
const cell = node("span", "ui-eq__sum-cell", eqText(get(k)) || "0");
|
||||
cell.dataset.eqSumCol = k;
|
||||
@@ -32,7 +45,7 @@ function cells(line: HTMLElement, get: (k: (typeof COLS)[number]) => unknown): v
|
||||
}
|
||||
}
|
||||
|
||||
function row(kind: string, name: string, get: (k: (typeof COLS)[number]) => unknown): HTMLElement {
|
||||
function row(kind: string, name: string, get: (k: Col) => unknown): HTMLElement {
|
||||
const line = node("div", "ui-eq__sum-row");
|
||||
line.dataset.eqSumKind = kind;
|
||||
line.append(node("span", "ui-eq__sum-name", name));
|
||||
@@ -40,19 +53,33 @@ function row(kind: string, name: string, get: (k: (typeof COLS)[number]) => unkn
|
||||
return line;
|
||||
}
|
||||
|
||||
/** M02 SK 줄 하나 — 이름 + 알약 + 원래 · 수량 / 더한 기초는 접힘 수식 */
|
||||
function itemRow(r: EqSumRow, open: Set<string>, at: number, hooks?: EqSumHooks): HTMLElement {
|
||||
const item = node("div", "ui-eq__sum-item");
|
||||
const line = node("div", "ui-eq__sum-row");
|
||||
line.dataset.eqSumKind = r.갈래;
|
||||
const name = node("span", "ui-eq__sum-name");
|
||||
/** 묶음 안 한 줄 「노무비 : 값」 */
|
||||
const part = (label: string, text: string): HTMLElement => {
|
||||
const line = node("div", "ui-eq__grp-line");
|
||||
line.append(
|
||||
node("span", "ui-eq__grp-key", label),
|
||||
node("span", "ui-eq__op", ":"),
|
||||
node("span", "ui-eq__grp-val", text),
|
||||
);
|
||||
line.dataset.eqGrpPart = label;
|
||||
return line;
|
||||
};
|
||||
|
||||
/** 기준 단가산출 묶음 하나 — 「가. 이름」 머리(+ 상태 · 접힘 수식) → 노 · 재 · 경 줄 → 소 계 */
|
||||
function group(r: EqSumRow, open: Set<string>, at: number, hooks?: EqSumHooks): HTMLElement {
|
||||
const box = node("div", "ui-eq__grp");
|
||||
box.dataset.eqSumItem = r.갈래;
|
||||
box.dataset.eqSumState = r.상태 ?? "바탕";
|
||||
const name = node("div", "ui-eq__grp-head");
|
||||
const title = r.갈래 === r.이름 ? (KIND[r.갈래] ?? r.이름) : r.이름;
|
||||
const tree = r.나무;
|
||||
const id = `sum:${at}:${r.이름}`;
|
||||
const body = node("div", "ui-eq__sum-tree");
|
||||
body.hidden = true;
|
||||
name.append(node("span", "ui-eq__grp-mark", `${mark(at)}.`));
|
||||
if (tree) {
|
||||
const head = createButton({
|
||||
label: r.이름,
|
||||
label: title,
|
||||
variant: "ghost",
|
||||
size: 22,
|
||||
iconStart: "chevronRight",
|
||||
@@ -66,68 +93,89 @@ function itemRow(r: EqSumRow, open: Set<string>, at: number, hooks?: EqSumHooks)
|
||||
if (!body.childElementCount)
|
||||
body.append(
|
||||
hooks?.body?.(r, at) ??
|
||||
createEqChain(tree.줄, {
|
||||
path: id,
|
||||
open,
|
||||
resultUnit: tree.결과단위,
|
||||
numbered: false,
|
||||
}),
|
||||
createEqChain(tree.줄, { path: id, open, resultUnit: tree.결과단위 }),
|
||||
);
|
||||
} else open.delete(id);
|
||||
item.dataset.open = on ? "1" : "";
|
||||
box.dataset.open = on ? "1" : "";
|
||||
body.hidden = !on;
|
||||
};
|
||||
name.append(head);
|
||||
if (open.has(id)) toggle(true);
|
||||
} else name.append(node("span", "ui-eq__sum-label", r.이름));
|
||||
} else name.append(node("span", "ui-eq__sum-label", title));
|
||||
if (r.상태 && r.상태 !== "바탕") {
|
||||
const detail = r.덮개?.length ? ` · ${r.덮개.join(" · ")}` : "";
|
||||
name.append(createBadge(`${r.상태}${detail}`, TONE[r.상태] ?? "neutral", { size: 18 }));
|
||||
}
|
||||
name.append(...(hooks?.head?.(r, at) ?? []));
|
||||
if (r.합밖) {
|
||||
name.append(createBadge("합 밖", "neutral", { size: 18 }));
|
||||
line.dataset.eqSumOut = "1";
|
||||
}
|
||||
const out = !!r.합밖;
|
||||
if (out) name.append(createBadge("합 밖", "neutral", { size: 18 }));
|
||||
const notes: string[] = [];
|
||||
if (r.합밖) notes.push(`합계에 안 듦 ${eqText(r.밖금액) || "0"}`);
|
||||
if (out) notes.push(`합계에 안 듦 ${eqText(r.밖금액) || "0"}`);
|
||||
if (r.상태 === "더함" && r.수량 !== undefined && r.수량 !== null)
|
||||
notes.push(`× ${eqText(r.수량)}`);
|
||||
if (r.원래 !== undefined && r.원래 !== null && r.상태 !== "더함")
|
||||
notes.push(`원래 ${eqText(r.원래)}`);
|
||||
if (r.까닭) notes.push(r.까닭);
|
||||
if (notes.length) name.append(node("span", "ui-eq__sum-was", notes.join(" · ")));
|
||||
line.dataset.eqSumState = r.상태 ?? "바탕";
|
||||
line.append(name);
|
||||
cells(line, (k) => r[k]);
|
||||
item.dataset.eqSumItem = r.갈래;
|
||||
item.append(line, body);
|
||||
return item;
|
||||
box.append(name, body);
|
||||
for (const k of PARTS) if (num(r[k]) !== 0) box.append(part(k, eqText(r[k])));
|
||||
const total = row("소계", "소 계", (k) => r[k]);
|
||||
total.classList.add("ui-eq__grp-total");
|
||||
if (out) total.dataset.eqSumOut = "1";
|
||||
box.append(total);
|
||||
return box;
|
||||
}
|
||||
|
||||
/** 맨 끝 「소 계」 묶음 — 묶음 소계를 더한 식 */
|
||||
function final(rows: EqSumRow[], at: number): HTMLElement {
|
||||
const box = node("div", "ui-eq__grp");
|
||||
box.dataset.eqSumItem = "소계";
|
||||
const head = node("div", "ui-eq__grp-head");
|
||||
head.append(
|
||||
node("span", "ui-eq__grp-mark", `${mark(at)}.`),
|
||||
node("span", "ui-eq__sum-label", "소 계"),
|
||||
);
|
||||
box.append(head);
|
||||
for (const k of PARTS) {
|
||||
const adds = rows.map((r) => num(r[k])).filter((v) => v !== 0);
|
||||
if (!adds.length) continue;
|
||||
const text = adds.map((v) => eqText(v)).join(" + ");
|
||||
const sum = eqText(adds.reduce((a, b) => a + b, 0));
|
||||
box.append(part(k, adds.length > 1 ? `${text} = ${sum}` : text));
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
export interface EqSumParts {
|
||||
/** 블록 이름 → 그 블록 식 줄 아래에 끼울 묶음 */
|
||||
inline: Map<string, HTMLElement>;
|
||||
/** 수식 영역 맨 끝 — 남은 묶음 · 소 계 · 끝수 · 합계 */
|
||||
tail: HTMLElement;
|
||||
}
|
||||
|
||||
export function createEqSum(
|
||||
tree: Pick<EqTree, "합계" | "소계" | "답까닭">,
|
||||
open: Set<string> = new Set(),
|
||||
hooks?: EqSumHooks,
|
||||
): HTMLElement | null {
|
||||
lineKeys: string[] = [],
|
||||
): EqSumParts | null {
|
||||
const sub = tree.소계;
|
||||
if (!sub?.줄.length) return null;
|
||||
const inline = new Map<string, HTMLElement>();
|
||||
const box = node("div", "ui-eq__sum");
|
||||
box.dataset.eqSum = "";
|
||||
const head = node("div", "ui-eq__sum-row ui-eq__sum-head");
|
||||
head.append(node("span", "ui-eq__sum-name", "소계"));
|
||||
for (const k of COLS) head.append(node("span", "ui-eq__sum-cell", k));
|
||||
box.append(head);
|
||||
sub.줄.forEach((r, at) => {
|
||||
if (r.상태) box.append(itemRow(r, open, at, hooks));
|
||||
else
|
||||
box.append(row(r.갈래, r.이름 === r.갈래 ? (KIND[r.갈래] ?? r.이름) : r.이름, (k) => r[k]));
|
||||
const one = group(r, open, at, hooks);
|
||||
if (r.갈래 === "블록" && lineKeys.includes(r.이름) && !inline.has(r.이름))
|
||||
inline.set(r.이름, one);
|
||||
else box.append(one);
|
||||
});
|
||||
box.append(final(sub.줄, sub.줄.length));
|
||||
const rest = sub.끝수;
|
||||
const odd = COLS.some((k) => Number(rest[k] ?? 0) !== 0);
|
||||
if (odd) box.append(row("끝수", "끝수", (k) => rest[k]));
|
||||
box.append(row("합계", "합계", (k) => tree.합계[k]));
|
||||
const note = [odd ? sub.끝수까닭 : "", tree.답까닭].filter(Boolean).join(" · ");
|
||||
if (note) box.append(node("div", "ui-eq__sum-note", note));
|
||||
return box;
|
||||
return { inline, tail: box };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user