diff --git a/A00_Common/formula_doc.css b/A00_Common/formula_doc.css new file mode 100644 index 000000000..b8f2c6eb6 --- /dev/null +++ b/A00_Common/formula_doc.css @@ -0,0 +1,309 @@ +/* 수식 문서 화면(802-3) — 왼쪽 기호 식 · 오른쪽 값 식 · 오른쪽 아래 기호 정의 · 색은 공통 값만 */ +.fdoc { + gap: var(--spacing-12); +} + +.fdoc__head { + display: flex; + flex-direction: column; + gap: var(--spacing-8); +} + +.fdoc__options { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8) var(--spacing-12); + align-items: flex-end; +} + +.fdoc__options > * { + width: auto; + min-width: 8em; +} + +.fdoc__sum { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--spacing-8); +} + +.fdoc__sumleft, +.fdoc__tools { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); +} + +.fdoc__headval { + font-size: var(--text-subheading); + font-variant-numeric: tabular-nums; +} + +.fdoc__resultsym { + width: 9em; +} + +.fdoc__hint { + margin: 0; +} + +.fdoc__marks { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-4) var(--spacing-8); + align-items: center; +} + +.fdoc__marks:empty { + display: none; +} + +/* 격자 — 왼쪽 기호 식 · 오른쪽 값 식(줄마다 같은 높이) */ +.fdoc__grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: var(--spacing-4) var(--spacing-24); + align-items: center; +} + +.fdoc__colhead { + color: var(--color-text-secondary); + font-size: var(--text-caption); + border-bottom: 1px solid var(--color-border); + padding-bottom: var(--spacing-4); +} + +.fdoc__cell { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0 var(--spacing-4); + min-width: 0; + min-height: 32px; +} + +.fdoc__sym, +.fdoc__valcell { + cursor: text; +} + +.fdoc__edit { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 0 var(--spacing-4); + min-width: 2em; + min-height: 24px; + border-radius: var(--radius-sm); + padding: 0 2px; +} + +.fdoc__edit:hover { + background: var(--color-hover); +} + +.fdoc__edit[data-editing] { + outline: 1px solid var(--color-accent); + background: var(--color-accent-soft); +} + +.fdoc__edit[data-bad], +.fdoc__money[data-bad] .fdoc__name { + text-decoration: underline wavy var(--color-danger); +} + +.fdoc__raw { + color: var(--color-danger); +} + +.fdoc__atom.is-val { + cursor: pointer; +} + +.fdoc__atom.is-val:hover { + outline: 1px solid var(--color-accent); +} + +.fdoc__atom.is-missing { + color: var(--color-danger); +} + +.fdoc__lit { + cursor: pointer; +} + +.fdoc__lit[data-editing] { + outline: 1px solid var(--color-accent); +} + +.fdoc__dash { + color: var(--color-text-muted); +} + +/* 금액 줄 · 묶음 */ +.fdoc__money { + align-items: flex-start; +} + +.fdoc__valcell.fdoc__money { + flex-direction: column; + align-items: flex-start; +} + +.fdoc__mrow { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 0 var(--spacing-4); +} + +.fdoc__name { + font-weight: var(--font-weight-medium, 500); + border-radius: var(--radius-sm); +} + +.fdoc__name[data-editing] { + outline: 1px solid var(--color-accent); + padding: 0 var(--spacing-4); +} + +.fdoc__cost, +.fdoc__tk { + color: var(--color-text-secondary); + font-size: var(--text-body-sm); +} + +.fdoc__tk { + margin-left: var(--spacing-8); +} + +.fdoc__amount { + font-weight: var(--font-weight-medium, 500); +} + +.fdoc__kind { + width: 6.5em; +} + +.fdoc__drop { + opacity: 0; +} + +.fdoc__money:hover .fdoc__drop, +.fdoc__drop:focus-visible { + opacity: 1; +} + +.fdoc__grouphead { + grid-column: 1 / -1; + padding-top: var(--spacing-8); +} + +.fdoc__group { + font-weight: var(--font-weight-medium, 500); +} + +.fdoc__total { + border-top: 1px solid var(--color-border); +} + +.fdoc__total.is-end { + border-top: 2px solid var(--color-border-strong); +} + +.fdoc__add { + color: var(--color-text-secondary); +} + +/* 기호 정의 — 값 식 아래 한 줄씩 */ +.fdoc__defs { + display: flex; + flex-direction: column; + gap: 2px; + padding-top: var(--spacing-12); + min-width: 0; +} + +.fdoc__defrow { + border-radius: var(--radius-sm); +} + +.fdoc__defrow.is-open { + background: var(--color-surface-sunken); + padding-bottom: var(--spacing-8); +} + +.fdoc__def { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-4); + padding: 2px var(--spacing-4); + cursor: pointer; + border-radius: var(--radius-sm); +} + +.fdoc__def:hover { + background: var(--color-hover); +} + +.fdoc__deflink { + font-size: var(--text-body-sm); +} + +.fdoc__defedit { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + padding: var(--spacing-8); +} + +.fdoc__fields { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8); + align-items: flex-end; +} + +.fdoc__fields > * { + width: auto; +} + +.fdoc__fields .ui-find { + width: 100%; + max-height: 220px; +} + +.fdoc__unused summary { + cursor: pointer; + color: var(--color-text-secondary); + font-size: var(--text-body-sm); + padding: var(--spacing-4); +} + +.fdoc__sub { + margin: var(--spacing-4) var(--spacing-8); + padding: var(--spacing-8); + border-left: 2px solid var(--color-accent); +} + +@container (max-width: 720px) { + .fdoc__grid { + grid-template-columns: minmax(0, 1fr); + } +} + +.fdoc__wrap, +.fdoc__val { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 0 var(--spacing-4); +} + +.fdoc__money .is-ghost { + opacity: 0.45; +} diff --git a/A00_Common/formula_doc_defs.ts b/A00_Common/formula_doc_defs.ts new file mode 100644 index 000000000..de7029ed2 --- /dev/null +++ b/A00_Common/formula_doc_defs.ts @@ -0,0 +1,372 @@ +/* ============================================================================= + * formula_doc_defs.ts + * 수식 문서 기호 정의 영역(PLAN 802-3 · 요구 660-2 · 660-5 · 660-6 · 664) — 값 식 아래 「기호 : 국문 이름 — 연결」 한 줄씩 + * 같은 기호 한 번 · 값 · 단위 · DB 키 없음 · 지금 탄 갈래로 닿는 기호만(나머지는 접힌 「쓰지 않는 기호」) + * 식에 새 기호를 쓰면 「정의 필요」 줄이 생김 → 눌러 연결 고름(그 줄 안에서 · 창 없음): + * 숫자 · 선택(목록) · 입력(범위) · 요소(인력 · 재료 · 기계 찾기) · 표(표 · 조건 식 · 칸) · 단가산출(다른 기초 · 넘길 입력 · 칸) · 식(식 줄) + * 기호 · 아래 · 위 첨자 · 국문 이름 · 단위 고침 · 정의 지움(쓰는 자리는 「정의 필요」로 남음) + * 고친 것은 부른 쪽 훅으로 — 여기선 문서를 안 고침 + * ========================================================================== */ + +import { createButton } from "@ui/ui_template_button"; +import { createBadge } from "@ui/ui_template_badge"; +import { el } from "@ui/ui_template_elements"; +import { createFindList, type FindRow } from "@ui/ui_template_find_list"; +import { createInputField, createSelectField } from "@ui/ui_template_input"; +import { createSegment } from "@ui/ui_template_segment"; +import { LINKS, splitId, type DocVar, type Link } from "../common_util/common_util_formula_doc"; +import type { DocTable } from "../common_util/common_util_formula_doc_table"; +import { symNode } from "./formula_doc_render"; +import { ft } from "./formula_doc_text"; + +const PLAIN = new Set(["적용", "값", "기준"]); +const ELEM_KINDS = ["인력", "재료", "기계"]; + +export interface Pick { + 키: string; + 이름?: string; + 규격?: string; + 단위?: string; + 조건?: string[]; + 칸?: string[]; + 입력?: string[]; +} + +export interface DefsHooks { + admin: boolean; + vars: Map; + names: Map; + lines: Set; + shown: string[]; + unused: string[]; + need: Set; + open: string | null; + onOpen: (id: string | null) => void; + setLink: (id: string, link: Link) => void; + setVar: (id: string, patch: Partial) => void; + rename: (id: string, sign: string, low: string, up: string) => string | null; + dropVar: (id: string) => void; + makeLine: (id: string) => void; + ask: (kind: string, words: string, group: string) => Promise<{ 후보: Pick[]; 더있음: boolean }>; + table: (key: string) => Promise; + subInputs: (key: string) => string[]; + sub: (id: string) => HTMLElement | null; +} + +const span = (cls: string, text?: string): HTMLSpanElement => + el("span", { className: cls, ...(text !== undefined ? { text } : {}) }); + +/** 연결 글 — 갈래 + 대상 이름(키 · 값 · 단위 없음) */ +export function linkText( + v: DocVar | undefined, + names: Map, + isLine: boolean, +): string { + const link = v?.연결; + const kind = link?.갈래 ?? "식"; + if (kind === "식") return isLine ? "식" : ft("Need"); + const ref = String(link?.키 ?? link?.표 ?? ""); + const named = (r: string): string => + r.includes("{") ? r.replace(/^[A-Z]+:/, "").replace(/[{}]/g, "") : names.get(r) || ""; + const parts: string[] = [kind]; + if (kind === "요소" && link?.재료) { + const m = link.재료 as Record; + parts.push(String(m.검색어 ?? m.이름 ?? m.상세구분 ?? m.구분 ?? "")); + } else if (ref) parts.push(named(ref)); + if (link?.칸 && !PLAIN.has(String(link.칸))) parts.push(String(link.칸)); + return parts.filter(Boolean).join(" · "); +} + +function field(label: string, value: string, onChange: (v: string) => void, width = "") { + const f = createInputField({ label, value, compact: true, size: 24 }); + f.input.addEventListener("change", () => onChange(f.input.value)); + if (width) f.root.style.width = width; + return f; +} + +/** 찾아 고르기(요소 · 표 · 단가산출) */ +function finder(h: DefsHooks, kind: string, onPick: (p: Pick) => void, group = { now: "인력" }) { + const filters: HTMLElement[] = []; + if (kind === "요소") + filters.push( + createSegment({ + options: ELEM_KINDS.map((k) => ({ value: k, label: k })), + value: group.now, + size: 24, + onChange: (v) => { + group.now = v; + void run(); + }, + }).root, + ); + const list = createFindList({ placeholder: ft("Find"), filters }); + let timer = 0; + let seq = 0; + const run = async (): Promise => { + const mine = ++seq; + const words = list.search.value.trim(); + if (!words && kind !== "요소") return list.setRows([]); + const got = await h + .ask(kind === "단가산출" ? "단가산출" : kind, words, group.now) + .catch(() => null); + if (mine !== seq || !got) return; + list.hint.textContent = got.더있음 ? ft("More") : got.후보.length ? "" : ft("Nothing"); + list.setRows( + got.후보.map((p): FindRow => ({ + key: p.키, + name: p.이름 ?? "", + sub: p.규격 ?? "", + unit: p.단위 ?? "", + onPick: () => onPick(p), + })), + ); + }; + list.search.addEventListener("input", () => { + window.clearTimeout(timer); + timer = window.setTimeout(() => void run(), 250); + }); + return list.root; +} + +function kindBody(id: string, v: DocVar | undefined, h: DefsHooks): HTMLElement[] { + const link: Link = { ...(v?.연결 ?? { 갈래: "식" }) }; + const kind = link.갈래 ?? "식"; + const set = (patch: Partial): void => h.setLink(id, { ...link, ...patch }); + const num = (s: string): number | string => { + const n = Number(s.replace(/,/g, "").trim()); + return s.trim() !== "" && Number.isFinite(n) ? n : s.trim(); + }; + switch (kind) { + case "숫자": + return [field(ft("Value"), String(link.값 ?? ""), (s) => set({ 값: num(s) })).root]; + case "선택": { + const list = (link.목록 ?? []).map(String); + const pick = createSelectField({ + label: ft("Default"), + options: list.map((x) => ({ value: x, text: x })), + value: String(link.값 ?? ""), + compact: true, + size: 24, + onChange: (x) => set({ 값: link.목록?.find((y) => String(y) === x) ?? x }), + }); + const items = field(ft("List"), list.join(" · "), (s) => + set({ + 목록: s + .split(/\s*[·,]\s*/) + .filter(Boolean) + .map((x) => num(x)), + }), + ); + return [items.root, pick.root]; + } + case "입력": { + const [low, high] = link.범위 ?? [null, null]; + const bound = (i: 0 | 1) => (s: string) => { + const r: [number | null, number | null] = [...(link.범위 ?? [null, null])] as never; + const n = num(s); + r[i] = typeof n === "number" ? n : null; + set(r[0] === null && r[1] === null ? { 범위: undefined } : { 범위: r }); + }; + return [ + field(ft("Value"), String(link.값 ?? ""), (s) => set({ 값: num(s) })).root, + field(ft("Low_Bound"), low === null ? "" : String(low), bound(0), "7em").root, + field(ft("High_Bound"), high === null ? "" : String(high), bound(1), "7em").root, + ]; + } + case "요소": { + const out: HTMLElement[] = []; + if (link.재료) + out.push( + span("ui-text ui-text--muted", `${ft("Material")} · ${linkText(v, h.names, false)}`), + ); + out.push( + finder(h, "요소", (p) => { + h.names.set(p.키, [p.이름, p.규격].filter(Boolean).join(" ")); + h.setLink(id, { 갈래: "요소", 키: p.키, ...(link.지역 ? { 지역: link.지역 } : {}) }); + }), + field(ft("Column"), String(link.칸 ?? ""), (s) => set({ 칸: s.trim() || undefined }), "9em") + .root, + field( + ft("Region"), + String(link.지역 ?? ""), + (s) => set({ 지역: s.trim() || undefined }), + "9em", + ).root, + ); + return out; + } + case "표": { + const out: HTMLElement[] = [ + finder(h, "표", (p) => { + h.names.set(p.키, p.이름 ?? ""); + void h + .table(p.키) + .then(() => h.setLink(id, { 갈래: "표", 표: p.키, 조건: {}, 칸: p.칸?.[0] ?? "" })); + }), + ]; + const tbl = link.표 ? h.table(String(link.표)) : null; + const cols = el("div", { className: "fdoc__fields" }); + out.push(cols); + void tbl?.then((t) => { + if (!t) return; + const names = [ + ...new Set([...Object.keys(t.값칸 ?? {}), ...t.줄.flatMap((r) => Object.keys(r))]), + ].filter((k) => !(k in (t.조건 ?? {}))); + cols.append( + createSelectField({ + label: ft("Column"), + options: names.map((x) => ({ value: x, text: x })), + value: String(link.칸 ?? ""), + compact: true, + size: 24, + onChange: (x) => set({ 칸: x }), + }).root, + ...Object.keys(t.조건 ?? {}).map((c) => { + const seen = [...new Set(t.줄.map((r) => r[c]).filter((x) => typeof x === "string"))]; + const f = field(`${ft("Cond")} ${c}`, String(link.조건?.[c] ?? ""), (s) => { + const box = { ...(link.조건 ?? {}) }; + if (s.trim()) box[c] = s.trim(); + else delete box[c]; + set({ 조건: box }); + }); + f.input.placeholder = seen + .slice(0, 4) + .map((x) => `'${x}'`) + .join(" · "); + return f.root; + }), + ); + }); + return out; + } + case "단가산출": { + const ins = link.키 ? h.subInputs(String(link.키)) : []; + return [ + finder(h, "단가산출", (p) => { + h.names.set(p.키, p.이름 ?? ""); + h.setLink(id, { 갈래: "단가산출", 키: p.키, 입력: {} }); + }), + createSelectField({ + label: ft("Column"), + options: [ + { value: "", text: ft("None") }, + ...["노무비", "재료비", "경비", "계"].map((x) => ({ value: x, text: x })), + ], + value: String(link.칸 ?? ""), + compact: true, + size: 24, + onChange: (x) => set({ 칸: x || undefined }), + }).root, + ...ins.map( + (k) => + field(`${ft("Inputs")} ${k}`, String(link.입력?.[k] ?? ""), (s) => { + const box = { ...(link.입력 ?? {}) }; + if (s.trim()) box[k] = s.trim(); + else delete box[k]; + set({ 입력: box }); + }).root, + ), + ]; + } + default: + return h.lines.has(id) + ? [span("ui-text ui-text--muted", ft("By_Line"))] + : [ + createButton({ + label: ft("Make_Line"), + variant: "ghost", + size: 24, + onClick: () => h.makeLine(id), + }), + ]; + } +} + +function editor(id: string, h: DefsHooks): HTMLElement { + const v = h.vars.get(id); + const [sign, low, up] = v ? [v.기호 ?? "", v.아래 ?? "", v.위 ?? ""] : splitId(id); + const box = el("div", { className: "fdoc__defedit" }); + const names = el("div", { className: "fdoc__fields" }); + const sym = { sign, low, up }; + const signF = field(ft("Sign"), sign, (s) => tryRename({ ...sym, sign: s.trim() }), "6em"); + const lowF = field(ft("Low"), low, (s) => tryRename({ ...sym, low: s.trim() }), "8em"); + const upF = field(ft("Up"), up, (s) => tryRename({ ...sym, up: s.trim() }), "6em"); + const tryRename = (now: typeof sym): void => { + const why = h.rename(id, now.sign, now.low, now.up); + if (why) signF.setError(why); + }; + names.append( + signF.root, + lowF.root, + upF.root, + field(ft("Name"), v?.이름 ?? "", (s) => h.setVar(id, { 이름: s.trim() })).root, + field(ft("Unit"), v?.단위 ?? "", (s) => h.setVar(id, { 단위: s.trim() }), "7em").root, + ); + const kind = v?.연결?.갈래 ?? "식"; + const kinds = createSegment({ + options: LINKS.map((k) => ({ value: k, label: k })), + value: kind, + size: 24, + ariaLabel: ft("Link"), + onChange: (k) => { + const base: Link = { 갈래: k }; + if (k === "숫자" || k === "입력") base.값 = ""; + if (k === "선택") Object.assign(base, { 목록: [], 값: "" }); + h.setLink(id, base); + if (k === "식" && !h.lines.has(id)) h.makeLine(id); + }, + }); + const body = el("div", { className: "fdoc__fields", children: kindBody(id, v, h) }); + const drop = createButton({ + label: ft("Drop_Var"), + variant: "danger-ghost", + size: 24, + onClick: () => h.dropVar(id), + }); + box.append(names, kinds.root, body, drop); + return box; +} + +function row(id: string, h: DefsHooks): HTMLElement { + const v = h.vars.get(id); + const need = h.need.has(id); + const head = el("div", { className: "fdoc__def", attrs: { "data-def": id } }); + const sym = span("fdoc__defsym"); + sym.append(symNode(id)); + head.append(sym, span("ui-eq__op", ":")); + if (need) head.append(createBadge(ft("Need"), "warning")); + else head.append(span("fdoc__defname", v?.이름 || "")); + const own = v?.이름 ?? ""; + // 이름에 이미 든 대상 이름 · 칸은 연결 글에서 뺌(같은 말 두 번 없음) + const text = linkText(v, h.names, h.lines.has(id)) + .split(" · ") + .filter((x, i) => i === 0 || !own.includes(x)) + .join(" · "); + if (!need) head.append(span("ui-text ui-text--muted fdoc__deflink", `— ${text}`)); + const wrap = el("div", { className: "fdoc__defrow", children: [head] }); + if (h.open === id) wrap.classList.add("is-open"); + head.addEventListener("click", () => h.onOpen(h.open === id ? null : id)); + if (h.open === id) { + const sub = h.sub(id); + if (sub) wrap.append(sub); + if (h.admin) wrap.append(editor(id, h)); + } + return wrap; +} + +/** 정의 영역 — 닿는 기호 줄 + 접힌 쓰지 않는 기호 */ +export function buildDefs(h: DefsHooks): HTMLElement { + const box = el("div", { className: "fdoc__defs" }); + box.append(el("div", { className: "fdoc__colhead", text: ft("Defs_Head") })); + h.shown.forEach((id) => box.append(row(id, h))); + if (h.unused.length) { + const fold = el("details", { + className: "fdoc__unused", + attrs: h.open && h.unused.includes(h.open) ? { open: "" } : {}, + children: [el("summary", { text: `${ft("Unused")} ${h.unused.length}` })], + }); + h.unused.forEach((id) => fold.append(row(id, h))); + box.append(fold); + } + return box; +} diff --git a/A00_Common/formula_doc_lines.ts b/A00_Common/formula_doc_lines.ts new file mode 100644 index 000000000..a0fb17ff4 --- /dev/null +++ b/A00_Common/formula_doc_lines.ts @@ -0,0 +1,386 @@ +/* ============================================================================= + * formula_doc_lines.ts + * 수식 문서 식 줄 · 금액 줄 · 묶음(PLAN 802-3 · 요구 660~664) — 왼쪽 기호 식 · 오른쪽 값 식 한 줄씩(격자 두 칸) + * 식 줄: 왼쪽 `Q = 3600 × q … ÷ Cm`(그 자리 고침 = 공용 `editEq` · 머리 = 함께) · 오른쪽 `= 값 식 = 결과 단위` + * (값 식 숫자 누름 = 그 자리 값 고침 · 기호 값 누름 = 정의 줄 열림 · 그 밖 누름 = 식 고침) + * 금액 줄: 경쟁사 꼴 `이름 : 단가 × 수량 = 금액`(÷ 한 줄) · 묶음 줄 = 노무비 · 재료비 · 경비 세 줄 + * 묶음: 돈 묶음 하나 = 머리 없이 「계」 · 둘 이상 = 「가. 이름」마다 「소계」 + 맨 끝 「합계」(지어낸 말 없음) + * 고친 것은 부른 쪽(`formula_doc_screen.ts`) 훅으로 — 여기선 문서를 안 고침 + * ========================================================================== */ + +import { createButton, createIconButton } from "@ui/ui_template_button"; +import { eqText } from "@ui/ui_template_equation"; +import { editEq, editNumber, serializeEq, tidyEq } from "@ui/ui_template_equation_edit"; +import { el } from "@ui/ui_template_elements"; +import { createSelectField } from "@ui/ui_template_input"; +import type { DocVar, FormulaDoc, MoneyLine, Node } from "../common_util/common_util_formula_doc"; +import type { DocAnswer } from "../common_util/common_util_formula_doc_calc"; +import { + litsOf, + renderNode, + symNode, + tryParse, + valueNode, + type Values, +} from "./formula_doc_render"; +import { ft } from "./formula_doc_text"; + +export const COSTS = ["노무비", "재료비", "경비"] as const; +const KIND = [...COSTS, "묶음"]; +const ORDER = "가나다라마바사아자차카타파하"; + +export type EditAt = { kind: "line" | "expr" | "price" | "qty" | "name"; i: number } | null; + +export interface LinesHooks { + admin: boolean; + doc: FormulaDoc; + answer: DocAnswer; + vars: Map; + /** 기호 · 금액 줄 이름 → 표지 글 */ + marks: Map; + auto: EditAt; + onLine: (i: number, text: string) => void; + onExpr: (i: number, text: string) => void; + onMoney: (i: number, patch: Partial | null) => void; + onLit: (target: "line" | "price" | "qty", i: number, at: number, value: number) => void; + onPickVar: (id: string, number?: HTMLElement) => void; + onAddLine: () => void; + onAddMoney: (group: string) => void; + onGroupName: (old: string, now: string) => void; + onRedraw: () => void; +} + +const span = (cls: string, text?: string): HTMLSpanElement => + el("span", { className: cls, ...(text !== undefined ? { text } : {}) }); + +const memo = new WeakMap(); +/** 기호 값 + 금액 줄 기호(그 줄 금액 — 뒤 금액 줄이 부름) */ +function vals(h: LinesHooks): Values { + let got = memo.get(h.answer); + if (!got) { + got = { ...(h.answer.값 ?? {}) }; + const outs = (h.answer.줄 ?? []) as { 금액?: number | null }[]; + (h.doc.금액 ?? []).forEach((l, i) => { + if (l.기호 && typeof outs[i]?.금액 === "number") got![String(l.기호)] = outs[i].금액; + }); + memo.set(h.answer, got); + } + return got; +} + +/** 식 글 → 그림(못 풀면 글 그대로 빨강) */ +function expr(text: unknown, h: LinesHooks, side: "sym" | "val", inline = false, cost?: string) { + const n = tryParse(text); + if (!n) { + const raw = span("fdoc__raw", String(text ?? "")); + if (String(text ?? "").trim()) raw.dataset.bad = "1"; + return { node: raw, tree: null as Node | null }; + } + const lits = side === "val" ? litsOf(n) : undefined; + return { + node: renderNode(n, { side, vals: vals(h), vars: h.vars, inline, cost, lits }), + tree: n, + }; +} + +/** 그 자리 고침 시작 — 누른 자리에 커서 */ +function startEdit( + host: HTMLElement, + h: LinesHooks, + commit: (text: string) => void, + at?: { x: number; y: number }, +): void { + if (host.dataset.editing) return; + editEq(host, { + at, + onInput: (text) => { + const body = text.includes(" = ") ? text.slice(text.indexOf(" = ") + 3) : text; + host.toggleAttribute("data-bad", !!body.trim() && !tryParse(body)); + }, + onCommit: commit, + onCancel: () => h.onRedraw(), + }); + // 전체 고르기 = 한 줄 글(선형 꼴)로 바꿔 통째로 고침 · 지움 + host.addEventListener("keydown", (ev) => { + if (!(ev.ctrlKey || ev.metaKey) || ev.key.toLowerCase() !== "a") return; + ev.preventDefault(); + host.textContent = tidyEq(serializeEq(host)); + window.getSelection()?.selectAllChildren(host); + }); +} + +/** 값 식 누름 — 수 = 그 자리 값 · 기호 값 = 정의 줄 · 그 밖 = 식 고침 */ +function onValueClick( + ev: MouseEvent, + host: HTMLElement, + h: LinesHooks, + target: "line" | "price" | "qty", + i: number, + editAll: () => void, +): void { + if (!h.admin || host.dataset.editing) return; + const t = ev.target as HTMLElement; + const lit = t.closest("[data-lit]"); + const atom = t.closest("[data-var]"); + if (lit && host.contains(lit)) { + ev.stopPropagation(); + editNumber(lit, (text) => { + const v = Number(text.replace(/,/g, "")); + if (Number.isFinite(v)) h.onLit(target, i, Number(lit.dataset.lit), v); + else h.onRedraw(); + }); + } else if (atom && host.contains(atom)) { + ev.stopPropagation(); + h.onPickVar(atom.dataset.var!, atom.querySelector(".ui-eq__num") ?? undefined); + } else editAll(); +} + +function lineRow(i: number, h: LinesHooks): HTMLElement[] { + const line = (h.doc.식 ?? [])[i]; + const head = String(line.기호 ?? ""); + const left = el("div", { className: "fdoc__cell fdoc__sym", attrs: { "data-line": String(i) } }); + const edit = span("fdoc__edit"); + if (head) { + const a = span("ui-eq__var fdoc__atom fdoc__head"); + a.dataset.tok = head; + a.append(symNode(head)); + edit.append(a, span("ui-eq__op", "=")); + } + edit.append(expr(line.식, h, "sym").node); + const bad = h.marks.get(head); + if (bad) { + edit.dataset.bad = "1"; + edit.title = bad; + } + left.append(edit); + const right = el("div", { + className: "fdoc__cell fdoc__valcell", + attrs: { "data-line": String(i) }, + }); + const vexpr = span("fdoc__edit"); + vexpr.append(expr(line.식, h, "val").node); + const got = vals(h)[head]; + right.append(span("ui-eq__op", "="), vexpr); + if (got !== null && got !== undefined && typeof got !== "object") { + right.append(span("ui-eq__op", "="), valueNode(got, unitOf(head, h))); + } else right.append(span("ui-eq__op", "="), span("fdoc__dash", "—")); + if (h.admin) { + const commitLine = (text: string): void => h.onLine(i, text); + left.addEventListener("click", (ev) => + startEdit(edit, h, commitLine, { x: ev.clientX, y: ev.clientY }), + ); + vexpr.addEventListener("click", (ev) => + onValueClick(ev, vexpr, h, "line", i, () => + startEdit(vexpr, h, (text) => h.onExpr(i, text), { x: ev.clientX, y: ev.clientY }), + ), + ); + if (h.auto?.kind === "line" && h.auto.i === i) startEdit(edit, h, commitLine); + } + return [left, right]; +} + +const unitOf = (id: string, h: LinesHooks): string => + h.vars.get(id)?.단위 || (h.doc.결과 === id ? (h.doc.결과단위 ?? "") : ""); + +function moneyRow(i: number, h: LinesHooks): HTMLElement[] { + const line = (h.doc.금액 ?? [])[i]; + const got = (h.answer.줄 ?? [])[i] as + { 비목?: Record; 금액?: number | null; 까닭?: string } | undefined; + const left = el("div", { className: "fdoc__cell fdoc__sym fdoc__money" }); + const name = span("fdoc__name", line.이름 || ft("Unnamed")); + const price = span("fdoc__edit"); + price.append(expr(line.단가, h, "sym", true).node); + const qty = span("fdoc__edit"); + const hasQty = line.수량 !== null && line.수량 !== undefined && line.수량 !== ""; + if (hasQty) qty.append(expr(line.수량, h, "sym", true).node); + else qty.append(span("ui-eq__num", "1")); + const times = span("ui-eq__op", "×"); + left.append(name, span("ui-eq__op", ":"), price); + // 수량 없음 = 1(흐리게 — 눌러 수량을 씀 · 묶음 줄은 단가 × 수량이어야 셈) + if (hasQty || h.admin) left.append(times, qty); + if (!hasQty) [times, qty].forEach((x) => x.classList.add("is-ghost")); + const bad = h.marks.get(line.이름 ?? ""); + if (bad) { + left.dataset.bad = "1"; + left.title = bad; + } + if (h.admin) { + const kind = createSelectField({ + options: KIND.map((k) => ({ value: k, text: k })), + value: line.비목 ?? "노무비", + compact: true, + size: 22, + ariaLabel: ft("Kind"), + onChange: (v) => h.onMoney(i, { 비목: v }), + }); + kind.root.classList.add("fdoc__kind"); + const drop = createIconButton({ + glyph: "✕", + title: ft("Drop_Line"), + size: 20, + variant: "ghost", + onClick: (ev) => { + ev.stopPropagation(); + h.onMoney(i, null); + }, + }); + drop.classList.add("fdoc__drop"); + left.prepend(kind.root); + left.append(drop); + name.addEventListener("click", () => + editName(name, line.이름 ?? "", (t) => h.onMoney(i, { 이름: t })), + ); + const put = (slot: "단가" | "수량") => (text: string) => h.onMoney(i, { [slot]: text }); + price.addEventListener("click", (ev) => + startEdit(price, h, put("단가"), { x: ev.clientX, y: ev.clientY }), + ); + qty.addEventListener("click", (ev) => + startEdit(qty, h, put("수량"), { x: ev.clientX, y: ev.clientY }), + ); + if (h.auto?.kind === "price" && h.auto.i === i) startEdit(price, h, put("단가")); + if (h.auto?.kind === "name" && h.auto.i === i) + editName(name, "", (t) => h.onMoney(i, { 이름: t })); + } + const right = el("div", { className: "fdoc__cell fdoc__valcell fdoc__money" }); + const costs = line.비목 === "묶음" ? [...COSTS] : [null]; + for (const cost of costs) { + const row = span("fdoc__mrow"); + if (cost) row.append(span("fdoc__cost", cost), span("ui-eq__op", ":")); + const pv = span("fdoc__edit"); + pv.append(expr(line.단가, h, "val", true, cost ?? undefined).node); + row.append(pv); + if (hasQty) { + const qv = span("fdoc__edit"); + qv.append(expr(line.수량, h, "val", true).node); + row.append(span("ui-eq__op", "×"), qv); + if (h.admin) + qv.addEventListener("click", (ev) => onValueClick(ev, qv, h, "qty", i, () => undefined)); + } + if (h.admin) + pv.addEventListener("click", (ev) => onValueClick(ev, pv, h, "price", i, () => undefined)); + const amount = cost ? got?.비목?.[cost] : got?.금액; + row.append(span("ui-eq__op", "=")); + if (typeof amount === "number") row.append(span("ui-eq__num fdoc__amount", eqText(amount))); + else { + const dash = span("fdoc__dash", "—"); + if (got?.까닭) dash.title = got.까닭; + row.append(dash); + } + right.append(row); + } + return [left, right]; +} + +/** 이름 그 자리 고침(글만) */ +function editName(node: HTMLElement, before: string, done: (text: string) => void): void { + node.textContent = before; + node.contentEditable = "true"; + node.dataset.editing = "1"; + let over = false; + const finish = (keep: boolean): void => { + if (over) return; + over = true; + node.contentEditable = "false"; + const text = (node.textContent ?? "").trim(); + if (keep && text !== before) done(text); + else node.textContent = before || ft("Unnamed"); + delete node.dataset.editing; + }; + node.addEventListener("keydown", (ev) => { + if (ev.key === "Enter" || ev.key === "Escape") { + ev.preventDefault(); + finish(ev.key === "Enter"); + } + }); + node.addEventListener("blur", () => finish(true), { once: true }); + queueMicrotask(() => { + node.focus(); + window.getSelection()?.selectAllChildren(node); + }); +} + +function totalRow(label: string, b: Record | null, cls: string): HTMLElement[] { + const left = el("div", { + className: `fdoc__cell fdoc__total ${cls}`, + children: [span("fdoc__name", label)], + }); + const right = el("div", { className: `fdoc__cell fdoc__total ${cls}` }); + for (const k of [...COSTS, "계"]) { + const v = b?.[k]; + right.append( + span("fdoc__tk", k), + typeof v === "number" ? span("ui-eq__num fdoc__amount", eqText(v)) : span("fdoc__dash", "—"), + ); + } + return [left, right]; +} + +/** 식 줄 · 금액 줄 · 묶음 · 계 — 격자 칸 차례 */ +export function buildLines(h: LinesHooks): HTMLElement[] { + const out: HTMLElement[] = []; + const add = (cells: HTMLElement[]): void => void out.push(...cells); + const pair = (left: HTMLElement | null, right: HTMLElement | null): void => + add([left ?? el("div"), right ?? el("div")]); + (h.doc.식 ?? []).forEach((_, i) => add(lineRow(i, h))); + if (h.admin) + pair( + el("div", { + className: "fdoc__cell", + children: [button(ft("Add_Line"), h.onAddLine)], + }), + null, + ); + const lines = h.doc.금액 ?? []; + const groups: string[] = []; + lines.forEach((l) => groups.includes(l.묶음 ?? "") || groups.push(l.묶음 ?? "")); + const many = groups.length > 1; + const bundles = (h.answer.묶음 ?? []) as Record[]; + const done = h.answer.셈됨; + groups.forEach((g, gi) => { + if (many) { + const head = span("fdoc__group", `${ORDER[gi] ?? gi + 1}. `); + const label = span("fdoc__name", g || ft("Unnamed")); + head.append(label); + if (h.admin) + label.addEventListener("click", () => editName(label, g, (t) => h.onGroupName(g, t))); + add([el("div", { className: "fdoc__cell fdoc__grouphead", children: [head] })]); // 두 칸 걸침 + } + lines.forEach((l, i) => (l.묶음 ?? "") === g && add(moneyRow(i, h))); + if (h.admin && lines.length) + pair( + el("div", { + className: "fdoc__cell", + children: [button(ft("Add_Money"), () => h.onAddMoney(g))], + }), + null, + ); + if (many) add(totalRow(ft("Subtotal"), done ? (bundles[gi] ?? null) : null, "is-sub")); + }); + if (h.admin && !lines.length) + pair( + el("div", { + className: "fdoc__cell", + children: [button(ft("Add_Money"), () => h.onAddMoney(""))], + }), + null, + ); + if (h.admin && lines.length) + pair( + el("div", { + className: "fdoc__cell", + children: [button(ft("Add_Group"), () => h.onAddMoney(ft("New_Group")))], + }), + null, + ); + if (lines.length) { + const sum = done ? (h.answer as unknown as Record) : null; + add(totalRow(many ? ft("Total") : ft("Sum"), sum, "is-end")); + } + return out; +} + +const button = (label: string, onClick: () => void): HTMLElement => { + const b = createButton({ label, variant: "ghost", size: 24, onClick }); + b.classList.add("fdoc__add"); + return b; +}; diff --git a/A00_Common/formula_doc_render.ts b/A00_Common/formula_doc_render.ts new file mode 100644 index 000000000..d04d88640 --- /dev/null +++ b/A00_Common/formula_doc_render.ts @@ -0,0 +1,373 @@ +/* ============================================================================= + * formula_doc_render.ts + * 수식 문서 그림(PLAN 802-3 · 요구 660~664) — 식 나무 → 조판 수식(분수 · 첨자 · 괄호) · 화면 공용(M01 · M02) + * 기호 식(side "sym") = 기호만(한글 이름 · 표 칸 글 · DB 키 없음) · 값 식(side "val") = 기호 자리에 값 + 단위 + * 만약 = 탄 갈래만(값을 알 때) · 반올림 · 올림 · 버림 자리 감싸개 숨김(662-4) — 숨긴 글은 `data-pre` · `data-post` + * 고침 글 꼴 = 공용 `serializeEq`(원자 `data-tok` · 분수 · 괄호 · 단위 뺌) — 보이는 그림이 곧 고침 자리 + * evalNode = 그림용 값(탄 갈래 · 값 식) — 셈 정본은 계산기(`common_util_formula_doc_calc.ts`) + * reach = 지금 탄 갈래로 닿는 기호(정의 영역에 보일 것) + * ========================================================================== */ + +import { eqText, symbolOf } from "@ui/ui_template_equation"; +import { + DocError, + linkNames, + parse, + splitId, + textOf, + varId, + type DocVar, + type FormulaDoc, + type Node, +} from "../common_util/common_util_formula_doc"; +import { binary, cut, isNum } from "../common_util/common_util_formula_doc_calc"; + +export type Values = Record; +const HIDE = new Set(["반올림", "올림", "버림"]); +const SHOW: Record = { + "*": "×", + "/": "÷", + "-": "−", + "<=": "≤", + ">=": "≥", + "!=": "≠", +}; +const PREC: Record = { + 또는: 1, + 그리고: 2, + "<": 3, + "<=": 3, + ">": 3, + ">=": 3, + "==": 3, + "!=": 3, + "+": 4, + "-": 4, + "*": 5, + "/": 5, +}; +const prec = (n: Node): number => (n[0] === "bin" ? PREC[n[1]] : n[0] === "neg" ? 6 : 9); + +/** 식 글 → 나무(못 풀면 null) */ +export function tryParse(text: unknown): Node | null { + if (text === null || text === undefined || String(text).trim() === "") return null; + try { + return parse(text); + } catch (e) { + if (e instanceof DocError) return null; + throw e; + } +} + +/** 그림용 값 — 모르면 undefined */ +export function evalNode(n: Node, vals: Values): unknown { + const ev = (x: Node): unknown => evalNode(x, vals); + try { + switch (n[0]) { + case "num": + case "str": + return n[1]; + case "var": { + const v = vals[n[1]]; + return v === null ? undefined : v; + } + case "field": { + const b = ev(n[1]); + return b && typeof b === "object" ? (b as Values)[n[2]] : undefined; + } + case "neg": { + const a = ev(n[1]); + return isNum(a) ? -a : undefined; + } + case "pct": { + const a = ev(n[1]); + return isNum(a) ? a / 100 : undefined; + } + case "bin": { + const a = ev(n[2]); + if (a === undefined) return undefined; + if (n[1] === "그리고" && !a) return a; + if (n[1] === "또는" && a) return a; + const b = ev(n[3]); + if (b === undefined) return undefined; + if (n[1] === "그리고" || n[1] === "또는") return b; + const got = binary(n[1], a, b); + return typeof got === "number" && !Number.isFinite(got) ? undefined : got; + } + case "call": { + const [, name, items] = n; + if (name === "만약") { + const t = items.length === 3 ? ev(items[0]) : undefined; + return t === undefined ? undefined : ev(t ? items[1] : items[2]); + } + const xs = items.map(ev); + if (!xs.every(isNum)) return undefined; + if (name === "최소") return Math.min(...(xs as number[])); + if (name === "최대") return Math.max(...(xs as number[])); + return cut(xs[0] as number, { 방법: name, 자리: Math.trunc((xs[1] as number) ?? 0) }); + } + } + } catch { + return undefined; + } +} + +/** 만약 — 탄 갈래 차례(1 참 · 2 거짓 · 0 모름) */ +export function takenOf(n: Node, vals: Values): 0 | 1 | 2 { + if (n[0] !== "call" || n[1] !== "만약" || n[2].length !== 3) return 0; + const t = evalNode(n[2][0], vals); + return t === undefined ? 0 : t ? 1 : 2; +} + +export interface Ctx { + side: "sym" | "val"; + vals: Values; + vars: Map; + /** ÷ 를 분수 대신 한 줄로(금액 줄 · 경쟁사 꼴) */ + inline?: boolean; + /** 묶음 값의 비목(금액 묶음 줄 노 · 재 · 경 줄) */ + cost?: string; + /** 수 마디 → 차례(값 식 숫자 고침) */ + lits?: Map; +} + +const span = (cls: string, text?: string): HTMLSpanElement => { + const s = document.createElement("span"); + s.className = cls; + if (text !== undefined) s.textContent = text; + return s; +}; + +/** 기호 글(`Q_시공^표`) → 기호 그림(첨자) */ +export function symNode(id: string): HTMLElement { + const [기호, 아래, 위] = splitId(id); + return symbolOf({ 기호, 아래, 위, 열쇠: id }); +} + +/** 값 글 + 단위 */ +export function valueNode(value: unknown, unit = ""): HTMLElement { + const box = span("fdoc__val"); + box.append(span("ui-eq__num", eqText(value))); + if (unit) box.append(span("ui-eq__unit", ` ${unit}`)); + return box; +} + +const paren = (inner: HTMLElement): HTMLElement => { + const p = span("ui-eq__paren"); + p.append(span("ui-eq__inner")); + p.firstElementChild!.append(inner); + return p; +}; + +const op = (o: string): HTMLElement => span("ui-eq__op", SHOW[o] ?? o); + +/** 감쌈 — 그림엔 안 보이는 앞 · 뒤 글(고침 때 그대로 되돌림) */ +const hidden = (pre: string, post: string, inner: HTMLElement): HTMLElement => { + const w = span("fdoc__wrap"); + w.dataset.pre = pre; + w.dataset.post = post; + w.append(inner); + return w; +}; + +function varNode(id: string, c: Ctx): HTMLElement { + if (c.side === "sym") { + const a = span("ui-eq__var fdoc__atom"); + a.dataset.tok = id; + a.dataset.var = id; + a.append(symNode(id)); + return a; + } + let v = c.vals[id]; + if (v && typeof v === "object" && c.cost) v = (v as Values)[c.cost]; + const a = span("ui-eq__var fdoc__atom is-val"); + a.dataset.tok = id; + a.dataset.var = id; + if (v === null || v === undefined || typeof v === "object") { + a.classList.add("is-missing"); // 값 없음 · 정의 필요 — 기호 그대로(빨강) + a.append(symNode(id)); + } else a.append(valueNode(v, c.vars.get(id)?.단위 ?? "")); + return a; +} + +/** 나무 → 조판 수식 */ +export function renderNode(n: Node, c: Ctx): HTMLElement { + const go = (x: Node): HTMLElement => renderNode(x, c); + switch (n[0]) { + case "num": { + const s = span("ui-eq__num fdoc__lit", eqText(n[1])); + const at = c.lits?.get(n); + if (at !== undefined) s.dataset.lit = String(at); + return s; + } + case "str": { + const s = span("ui-eq__str fdoc__atom", n[1]); + s.dataset.tok = `'${n[1]}'`; + return s; + } + case "var": + return varNode(n[1], c); + case "field": { + if (c.side === "val" && n[1][0] === "var") { + const v = c.vals[n[1][1]]; + const got = v && typeof v === "object" ? (v as Values)[n[2]] : undefined; + if (isNum(got)) { + const a = span("ui-eq__var fdoc__atom is-val"); + a.dataset.tok = textOf(n); + a.dataset.var = n[1][1]; + a.append(valueNode(got)); + return a; + } + } + const a = span("ui-eq__var fdoc__atom"); + a.dataset.tok = textOf(n); + if (n[1][0] === "var") a.dataset.var = n[1][1]; + const base = n[1][0] === "var" ? symNode(n[1][1]) : span("", textOf(n[1])); + a.append(base, span("ui-eq__script", `.${n[2]}`)); + return a; + } + case "neg": { + const s = span("ui-eq__body"); + s.append(op("-"), n[1][0] === "bin" ? paren(go(n[1])) : go(n[1])); + return s; + } + case "pct": { + const s = span("ui-eq__body"); + s.append(prec(n[1]) < 9 ? paren(go(n[1])) : go(n[1]), span("ui-eq__op", "%")); + return s; + } + case "call": { + const [, name, items] = n; + if (name === "만약") { + const pick = takenOf(n, c.vals); + if (pick) { + const [t, a, b] = items.map(textOf); + return pick === 1 + ? hidden(`만약(${t}, `, `, ${b})`, go(items[1])) + : hidden(`만약(${t}, ${a}, `, ")", go(items[2])); + } + } + if (HIDE.has(name) && items.length >= 1 && items.length <= 2) { + const post = items.length === 2 ? `, ${textOf(items[1])})` : ")"; + return hidden(`${name}(`, post, go(items[0])); + } + const s = span("ui-eq__body"); + const inner = span("ui-eq__body"); + items.forEach((x, i) => { + if (i) inner.append(span("ui-eq__op", ",")); + inner.append(go(x)); + }); + s.append(span("ui-eq__fn", name), paren(inner)); + return s; + } + case "bin": { + const [, o, a, b] = n; + if (o === "/" && !c.inline) { + const f = span("ui-eq__frac"); + const top = span("ui-eq__top"); + const low = span("ui-eq__bottom"); + top.append(go(a)); + low.append(go(b)); + f.append(top, low); + return f; + } + const mine = PREC[o]; + const s = span("ui-eq__body"); + const left = go(a); + const right = go(b); + s.append( + prec(a) < mine || (mine === 3 && prec(a) === 3) ? paren(left) : left, + op(o), + prec(b) <= mine ? paren(right) : right, + ); + return s; + } + } +} + +/** 수 마디 차례(나무 걸음 차례) */ +export function litsOf(n: Node): Map { + const out = new Map(); + const go = (x: Node): void => { + if (x[0] === "num") out.set(x, out.size); + if (x[0] === "bin") { + go(x[2]); + go(x[3]); + } else if (x[0] === "neg" || x[0] === "pct" || x[0] === "field") go(x[1]); + else if (x[0] === "call") x[2].forEach(go); + }; + go(n); + return out; +} + +/** 차례 번째 수를 바꾼 식 글 */ +export function swapLit(text: string, at: number, now: number): string { + const n = tryParse(text); + if (!n) return text; + const lits = litsOf(n); + for (const [node, i] of lits) if (i === at) node[1] = now; + for (const [node] of lits) (node as [string, number, string])[2] = String(node[1]); + return textOf(n); +} + +/** 탄 갈래로 부르는 이름(만약 안 탄 갈래 · 숨김 자리 인자 빼고) */ +export function namesTaken(n: Node, vals: Values, out: string[] = []): string[] { + const add = (x: string): void => { + if (!out.includes(x)) out.push(x); + }; + switch (n[0]) { + case "var": + add(n[1]); + break; + case "bin": + namesTaken(n[2], vals, out); + namesTaken(n[3], vals, out); + break; + case "neg": + case "pct": + case "field": + namesTaken(n[1], vals, out); + break; + case "call": { + const pick = takenOf(n, vals); + const items = pick ? [n[2][0], n[2][pick]] : n[2]; + items.forEach((x) => namesTaken(x, vals, out)); + break; + } + } + return out; +} + +const RESERVED_MONEY = new Set(["노무비", "재료비", "경비", "번지", "단계"]); + +/** 정의 영역에 보일 기호 — 식 줄 · 금액 줄 · 결과에서 지금 탄 갈래로 닿는 것(보이는 차례) */ +export function reach(doc: FormulaDoc, vals: Values): string[] { + const vars = new Map((doc.변수 ?? []).map((v) => [varId(v), v])); + const lines = new Map((doc.식 ?? []).map((l) => [String(l.기호 ?? ""), l.식])); + const money = new Set((doc.금액 ?? []).map((l) => String(l.기호 ?? "")).filter(Boolean)); + const out: string[] = []; + const visit = (name: string): void => { + if (!name || out.includes(name) || RESERVED_MONEY.has(name) || money.has(name)) return; + out.push(name); + if (lines.has(name)) { + const n = tryParse(lines.get(name)); + if (n) namesTaken(n, vals).forEach(visit); + } + const v = vars.get(name); + if (v?.연결 && v.연결.갈래 !== "식") linkNames(v.연결).forEach(visit); + }; + const from = (text: unknown): void => { + const n = tryParse(text); + if (n) namesTaken(n, vals).forEach(visit); + }; + for (const l of doc.식 ?? []) { + visit(String(l.기호 ?? "")); + from(l.식); + } + for (const l of doc.금액 ?? []) { + from(l.수량); + from(l.단가); + } + if (doc.결과) visit(String(doc.결과)); + return out; +} diff --git a/A00_Common/formula_doc_screen.ts b/A00_Common/formula_doc_screen.ts new file mode 100644 index 000000000..7b4278764 --- /dev/null +++ b/A00_Common/formula_doc_screen.ts @@ -0,0 +1,678 @@ +/* ============================================================================= + * formula_doc_screen.ts + * 단가산출 기초 수식 문서 화면(PLAN 802-3 · 요구 660~665 · 요구서_664 3 장) — 공용(M01 · 뒤에 M02 = 길 앞머리만 바꿈) + * 위 = 옵션 가로(선택 · 입력 기호 · 바꾸면 바로 다시 그림 · 문서는 안 바뀜) · 결과/계 · [수식 저장] · [고침 버림](관리자) + * 가운데 = 격자 두 칸 — 왼쪽 기호 식 · 오른쪽 값 식(`formula_doc_lines.ts`) · 오른쪽 아래 기호 정의(`formula_doc_defs.ts`) + * 빈 문서에서 시작 — [+ 식 줄] 에 `Q = 3600 × q × K × f × E / Cm` 침 → 새 기호마다 「정의 필요」 → 연결 고름 + * 셈 = 브라우저 계산기(`calcDoc`) 즉시 · 연결 값은 서버 기록 캐시(`common_util_formula_doc_cache.ts`) + * · 캐시에 없는 연결만 뒤에서 `POST {길}/preview`(옛 요청 끊음) → 캐시에 더해 다시 셈 + * 고침 = 캐시(sessionStorage `opt.cache`)에 쌓고 [수식 저장] 한 번에 `POST {길}/save`(서버가 정본으로 다시 셈 · 파일에 씀) + * 무엇을 지워도 오류 없음 — 표지(정의 필요 · 순환 · 0 나눔 …) + 금액 「—」 + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import { createBadge } from "@ui/ui_template_badge"; +import { createButton } from "@ui/ui_template_button"; +import { eqText } from "@ui/ui_template_equation"; +import { editNumber } from "@ui/ui_template_equation_edit"; +import { + el, + hideLoadingOverlay, + showConfirmDialog, + showLoadingOverlay, + showToast, +} from "@ui/ui_template_elements"; +import { createInputField, createSelectField } from "@ui/ui_template_input"; +import { + DocError, + goodId, + rename, + splitId, + tidy, + varId, + type DocVar, + type FormulaDoc, + type Link, + type MoneyLine, +} from "../common_util/common_util_formula_doc"; +import { calcDoc, type DocAnswer } from "../common_util/common_util_formula_doc_calc"; +import { createDocCache, type DocRecord } from "../common_util/common_util_formula_doc_cache"; +import type { DocTable } from "../common_util/common_util_formula_doc_table"; +import { buildDefs, type Pick } from "./formula_doc_defs"; +import { buildLines, type EditAt } from "./formula_doc_lines"; +import { evalNode, reach, swapLit, tryParse } from "./formula_doc_render"; +import { ft } from "./formula_doc_text"; +import "./formula_doc.css"; + +export interface DocScreenOptions { + /** 길 앞머리(`/m01/logic/doc`) — GET ?key · POST /preview · /save · /links · GET /table */ + base: string; + admin: boolean; + /** sessionStorage 열쇠 */ + cache: string; +} + +interface Loaded { + 문서: FormulaDoc; + 셈: DocAnswer; + 연결: DocRecord; + 새?: boolean; +} + +const clone = (v: T): T => JSON.parse(JSON.stringify(v)) as T; + +/** 머리 = 식 — 첫 「=」(비교 ==, <=, >=, != 빼고) */ +export function splitLine(text: string): [string | null, string] { + const m = /^([^=<>!]*?)\s*(?!=])=(?!=)\s*([\s\S]*)$/.exec(text); + if (!m) return [null, text.trim()]; + return [m[1].trim(), m[2].trim()]; +} + +export function createDocScreen(opt: DocScreenOptions) { + const head = el("div", { className: "fdoc__head" }); + const marks = el("div", { className: "fdoc__marks" }); + const grid = el("div", { className: "fdoc__grid" }); + const root = el("div", { + className: "fdoc ui-eq", + attrs: { "data-formula-doc": "" }, + children: [head, marks, grid], + }); + let key = ""; + let saved = ""; + let fresh = false; + let doc: FormulaDoc | null = null; + let answer: DocAnswer | null = null; + let cache = createDocCache(); + let given: Record = {}; + let openDef: string | null = null; + let auto: EditAt = null; + let seq = 0; + let asked = ""; + let ctl: AbortController | null = null; + let waiting = false; + const tables = new Map>(); + + const call = async (path: string, body?: unknown, signal?: AbortSignal): Promise => { + const r = await fetch(`${API_BASE_URL}${opt.base}${path}`, { + method: body === undefined ? "GET" : "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + signal, + }); + const data = (await r.json().catch(() => ({}))) as T & { detail?: unknown }; + if (!r.ok) throw new Error(String(data.detail ?? r.statusText)); + return data; + }; + + // ── 캐시(sessionStorage) ── + const drafts = (): Record => { + try { + return JSON.parse(sessionStorage.getItem(opt.cache) ?? "{}") as Record; + } catch { + return {}; + } + }; + const dirty = (): boolean => !!doc && JSON.stringify(doc) !== saved; + const keep = (): void => { + const all = drafts(); + if (dirty()) all[key] = doc!; + else delete all[key]; + try { + sessionStorage.setItem(opt.cache, JSON.stringify(all)); + } catch { + /* 캐시가 막혀도 화면 안 고침은 남음 */ + } + }; + + // ── 셈 ── + const usable = (): Record => { + const out: Record = {}; + for (const v of doc?.변수 ?? []) { + const k = v.열쇠; + if (!k || !(k in given)) continue; + const kind = v.연결?.갈래; + if (kind === "입력") out[k] = given[k]; + if (kind === "선택" && (v.연결?.목록 ?? []).some((x) => String(x) === String(given[k]))) + out[k] = (v.연결!.목록 ?? []).find((x) => String(x) === String(given[k])); + } + return out; + }; + const recalc = (): void => { + if (!doc) return; + answer = calcDoc(doc, cache.links, usable()); + if (cache.takeMissing()) ask(); + }; + /** 캐시에 없는 연결 — 뒤에서 서버 기록을 받아 더함(같은 문서 · 입력은 한 번만) */ + const ask = (): void => { + const snap = JSON.stringify([doc, usable()]); + if (snap === asked) return; + asked = snap; + ctl?.abort(); + ctl = new AbortController(); + const mine = ++seq; + waiting = true; + void call<{ 셈: DocAnswer; 연결: DocRecord }>( + "/preview", + { 문서: doc, 입력: usable() }, + ctl.signal, + ) + .then((got) => { + if (mine !== seq) return; + cache.merge(got.연결); + waiting = false; + recalc(); + draw(); + }) + .catch(() => { + if (mine === seq) waiting = false; + }); + }; + + const change = (fn: (d: FormulaDoc) => void): void => { + if (!doc) return; + fn(doc); + keep(); + recalc(); + draw(); + }; + + // ── 변수 ── + const ensure = (d: FormulaDoc, id: string): DocVar => { + const have = (d.변수 ??= []).find((v) => varId(v) === id); + if (have) return have; + const [기호, 아래, 위] = splitId(id); + const v: DocVar = { 열쇠: id, 기호, 아래, 위, 이름: "", 단위: "", 연결: { 갈래: "식" } }; + d.변수.push(v); + return v; + }; + const tidyText = (t: string): string => (t.trim() ? tidy(t.trim()) : ""); + + const onLine = (i: number, text: string): void => + change((d) => { + const lines = (d.식 ??= []); + const line = lines[i]; + if (!line) return; + if (!text.trim()) { + lines.splice(i, 1); + return; + } + const [h, body] = splitLine(text); + const old = String(line.기호 ?? ""); + if (h !== null && h !== old) { + line.기호 = h; + const was = (d.변수 ?? []).find((v) => varId(v) === old && (v.연결?.갈래 ?? "식") === "식"); + if (was && goodId(h) && !(d.변수 ?? []).some((v) => varId(v) === h)) { + const [기호, 아래, 위] = splitId(h); + Object.assign(was, { 기호, 아래, 위 }); + } + } + line.식 = tidyText(body); + }); + + const onMoney = (i: number, patch: Partial | null): void => + change((d) => { + const lines = (d.금액 ??= []); + if (!patch) { + lines.splice(i, 1); + return; + } + const line = lines[i]; + for (const [k, v] of Object.entries(patch)) { + const text = typeof v === "string" && (k === "단가" || k === "수량") ? tidyText(v) : v; + if (k === "수량" && text === "") delete line.수량; + else (line as Record)[k] = text; + } + if ("이름" in patch && !line.단가) auto = { kind: "price", i }; + }); + + const onLit = (target: "line" | "price" | "qty", i: number, at: number, value: number): void => + change((d) => { + if (target === "line") d.식![i].식 = swapLit(String(d.식![i].식 ?? ""), at, value); + else { + const line = d.금액![i]; + const slot = target === "price" ? "단가" : "수량"; + line[slot] = swapLit(String(line[slot] ?? ""), at, value); + } + }); + + const onPickVar = (id: string, num?: HTMLElement): void => { + const v = doc?.변수?.find((x) => varId(x) === id); + if (opt.admin && num && v?.연결?.갈래 === "숫자") { + editNumber(num, (t) => { + const n = Number(t.replace(/,/g, "")); + change((d) => (ensure(d, id).연결 = { ...v.연결, 값: Number.isFinite(n) ? n : t })); + }); + return; + } + openDef = id; + draw(); + grid.querySelector(`[data-def="${CSS.escape(id)}"]`)?.scrollIntoView({ block: "nearest" }); + }; + + const tableOf = (k: string): Promise => { + if (!tables.has(k)) + tables.set( + k, + call(`/table?key=${encodeURIComponent(k)}`) + .then((t) => { + if (t.끊김) return null; + cache.putTable(k, t); + return t; + }) + .catch(() => null), + ); + return tables.get(k)!; + }; + + const subDoc = (k: string): FormulaDoc | null => { + try { + return cache.links.doc(k) ?? null; + } catch { + cache.takeMissing(); + return null; + } + }; + + /** 단가산출 연결 펼침 — 그 문서의 식 · 값 식(읽기만) */ + const subBlock = (id: string): HTMLElement | null => { + const link = doc?.변수?.find((v) => varId(v) === id)?.연결; + if (link?.갈래 !== "단가산출" || !link.키 || link.키.includes("{")) return null; + const sub = subDoc(link.키); + if (!sub) return null; + const inputs: Record = {}; + for (const [k, t] of Object.entries(link.입력 ?? {})) { + const n = tryParse(t); + const v = n ? evalNode(n, answer?.값 ?? {}) : undefined; + if (v !== undefined) inputs[k] = v; + } + const got = calcDoc(sub, cache.links, inputs); + cache.takeMissing(); + const box = el("div", { className: "fdoc__grid fdoc__sub" }); + box.append( + ...buildLines({ + admin: false, + doc: sub, + answer: got, + vars: new Map((sub.변수 ?? []).map((v) => [varId(v), v])), + marks: new Map(), + auto: null, + onLine: () => undefined, + onExpr: () => undefined, + onMoney: () => undefined, + onLit: () => undefined, + onPickVar: () => undefined, + onAddLine: () => undefined, + onAddMoney: () => undefined, + onGroupName: () => undefined, + onRedraw: () => undefined, + }), + ); + if (sub.결과) { + const r = got.결과; + box.append( + el("div", { className: "fdoc__cell fdoc__total", text: ft("Result") }), + el("div", { + className: "fdoc__cell fdoc__total", + text: typeof r === "number" ? `${eqText(r)} ${sub.결과단위 ?? ""}` : "—", + }), + ); + } + return box; + }; + + // ── 그림 ── + const optionBar = (): HTMLElement[] => { + const out: HTMLElement[] = []; + for (const v of doc?.변수 ?? []) { + const kind = v.연결?.갈래; + const k = v.열쇠; + if (!k || (kind !== "선택" && kind !== "입력")) continue; + const label = v.이름 || varId(v); + const now = k in given ? given[k] : v.연결?.값; + const set = (x: unknown): void => { + given[k] = x; + recalc(); + draw(); + }; + if (kind === "선택") { + const list = (v.연결?.목록 ?? []).map(String); + const f = createSelectField({ + label, + options: list.map((x) => ({ value: x, text: x })), + value: String(now ?? ""), + compact: true, + size: 28, + onChange: (x) => set(x), + }); + f.root.dataset.option = k; + out.push(f.root); + } else { + const f = createInputField({ + label, + value: now === undefined || now === null ? "" : String(now), + compact: true, + size: 28, + unit: v.단위 || undefined, + }); + f.input.addEventListener("change", () => { + const t = f.input.value.trim(); + const n = Number(t.replace(/,/g, "")); + set(t !== "" && Number.isFinite(n) ? n : t); + }); + f.root.dataset.option = k; + out.push(f.root); + } + } + return out; + }; + + const headLine = (): HTMLElement => { + const a = answer; + const bits: (HTMLElement | string)[] = []; + if (doc?.결과) { + const r = a?.결과; + bits.push( + el("strong", { text: ft("Result") }), + el("span", { + className: "fdoc__headval", + attrs: { "data-total": "" }, + text: typeof r === "number" ? `${eqText(r)} ${doc.결과단위 ?? ""}` : "—", + }), + ); + } else { + const total = a?.계; + bits.push( + el("strong", { text: a?.머리 === "합계" ? ft("Total") : ft("Sum") }), + el("span", { + className: "fdoc__headval", + attrs: { "data-total": "" }, + text: typeof total === "number" ? `${eqText(total)} ${doc?.결과단위 ?? ""}` : "—", + }), + ); + } + if (opt.admin && doc && !(doc.금액 ?? []).length) { + const f = createInputField({ + placeholder: ft("Result_Sym"), + value: doc.결과 ?? "", + compact: true, + size: 24, + }); + f.input.addEventListener("change", () => + change((d) => { + const t = f.input.value.trim(); + if (t) d.결과 = t; + else delete d.결과; + }), + ); + f.root.classList.add("fdoc__resultsym"); + bits.push(f.root); + } + if (waiting) + bits.push(el("span", { className: "ui-text ui-text--muted", text: ft("Loading") })); + if (fresh && !dirty()) + bits.push(el("span", { className: "ui-text ui-text--muted", text: ft("Fresh") })); + if (dirty()) bits.push(createBadge(ft("Dirty"), "warning")); + const tools = opt.admin + ? [ + createButton({ + label: ft("Discard"), + variant: "ghost", + size: 28, + disabled: !dirty(), + onClick: () => void discard(), + }), + createButton({ + label: ft("Save"), + variant: "filled", + size: 28, + disabled: !dirty(), + onClick: () => void save(), + }), + ] + : []; + return el("div", { + className: "fdoc__sum", + children: [ + el("div", { className: "fdoc__sumleft", children: bits }), + el("div", { className: "fdoc__tools", children: tools }), + ], + }); + }; + + const markBox = (): void => { + const list = answer?.표지 ?? []; + marks.replaceChildren(); + if (!list.length) return; + const count = new Map(); + for (const m of list) count.set(m.갈래, (count.get(m.갈래) ?? 0) + 1); + marks.append( + ...[...count].map(([k, n]) => + createBadge(`${k} ${n}`, k === "빈 문서" ? "neutral" : "warning"), + ), + ...list + .slice(0, 4) + .map((m) => el("span", { className: "ui-text ui-text--muted fdoc__marktext", text: m.글 })), + ); + }; + + const draw = (): void => { + if (!doc || !answer) { + head.replaceChildren(); + marks.replaceChildren(); + grid.replaceChildren(); + return; + } + const vals = answer.값 ?? {}; + const vars = new Map((doc.변수 ?? []).map((v) => [varId(v), v])); + const lineSet = new Set((doc.식 ?? []).map((l) => String(l.기호 ?? "")).filter(Boolean)); + const shown = reach(doc, vals); + const markMap = new Map(); + for (const m of answer.표지 ?? []) { + const at = m.기호 || (m as { 자리?: string }).자리; + if (at && !markMap.has(at)) markMap.set(at, m.글); + } + const options = optionBar(); + head.replaceChildren( + ...(options.length ? [el("div", { className: "fdoc__options", children: options })] : []), + headLine(), + ...(opt.admin + ? [el("p", { className: "ui-text ui-text--muted fdoc__hint", text: ft("Hint") })] + : []), + ); + markBox(); + const lines = buildLines({ + admin: opt.admin, + doc, + answer, + vars, + marks: markMap, + auto, + onLine, + onExpr: (i, t) => change((d) => (d.식![i].식 = tidyText(t))), + onMoney, + onLit, + onPickVar, + onAddLine: () => { + auto = { kind: "line", i: (doc!.식 ?? []).length }; + change((d) => (d.식 ??= []).push({ 기호: "", 식: "" })); + }, + onAddMoney: (g) => { + auto = { kind: "name", i: (doc!.금액 ?? []).length }; + change((d) => + (d.금액 ??= []).push({ 이름: "", 비목: "노무비", 단가: "", ...(g ? { 묶음: g } : {}) }), + ); + }, + onGroupName: (old, now) => + change((d) => + (d.금액 ?? []).forEach((l) => { + if ((l.묶음 ?? "") !== old) return; + if (now) l.묶음 = now; + else delete l.묶음; + }), + ), + onRedraw: () => redraw(), + }); + auto = null; + const defs = buildDefs({ + admin: opt.admin, + vars, + names: cache.names, + lines: lineSet, + shown, + unused: [...vars.keys()].filter((id) => !shown.includes(id)), + need: new Set( + shown.filter((id) => !lineSet.has(id) && (vars.get(id)?.연결?.갈래 ?? "식") === "식"), + ), + open: openDef, + onOpen: (id) => { + openDef = id; + draw(); + }, + setLink: (id, link: Link) => + change((d) => { + const clean = Object.fromEntries(Object.entries(link).filter(([, x]) => x !== undefined)); + ensure(d, id).연결 = clean as Link; + }), + setVar: (id, patch) => change((d) => Object.assign(ensure(d, id), patch)), + rename: (id, sign, low, up) => { + const copy = clone(doc!); + try { + rename(copy, id, sign, low, up); + } catch (e) { + if (e instanceof DocError) return ft("Bad_Sign"); + throw e; + } + openDef = varId({ 기호: sign, 아래: low, 위: up }); + change((d) => Object.assign(d, copy)); + return null; + }, + dropVar: (id) => { + openDef = null; + change((d) => { + d.변수 = (d.변수 ?? []).filter((v) => varId(v) !== id); + d.식 = (d.식 ?? []).filter((l) => l.기호 !== id); + }); + }, + makeLine: (id) => { + if (lineSet.has(id)) return; + auto = { kind: "line", i: (doc!.식 ?? []).length }; + change((d) => (d.식 ??= []).push({ 기호: id, 식: "" })); + }, + ask: (kind, words, group) => + call<{ 후보: Pick[]; 더있음: boolean }>("/links", { 갈래: kind, 검색: words, 분류: group }), + table: tableOf, + subInputs: (k) => + (subDoc(k)?.변수 ?? []) + .filter((v) => ["선택", "입력", "숫자"].includes(v.연결?.갈래 ?? "") && v.열쇠) + .map((v) => v.열쇠!), + sub: subBlock, + }); + grid.replaceChildren( + el("div", { className: "fdoc__colhead", text: ft("Sym_Head") }), + el("div", { className: "fdoc__colhead", text: ft("Val_Head") }), + ...lines, + el("div"), + defs, + ); + }; + + /** 고침 취소 — 빈 새 줄은 걷고 다시 그림 */ + const redraw = (): void => { + if (!doc) return; + const before = JSON.stringify(doc); + doc.식 = (doc.식 ?? []).filter((l) => String(l.기호 ?? "") || String(l.식 ?? "")); + doc.금액 = (doc.금액 ?? []).filter((l) => l.이름 || l.단가 || l.수량); + if (JSON.stringify(doc) !== before) { + keep(); + recalc(); + } + draw(); + }; + + const take = (got: Loaded): void => { + cache.merge(got.연결); + saved = JSON.stringify(got.문서); + doc = clone(got.문서); + }; + + const save = async (): Promise => { + if (!doc) return; + showLoadingOverlay(); + try { + const got = await call("/save", { 문서: doc }); + take(got); + fresh = false; + keep(); + recalc(); + draw(); + showToast(ft("Saved"), "success"); + } catch (e) { + showToast(`${ft("Save_Failed")} — ${e instanceof Error ? e.message : ""}`, "error"); + } finally { + hideLoadingOverlay(); + } + }; + + const discard = async (): Promise => { + if (!doc || !dirty()) return; + doc = JSON.parse(saved) as FormulaDoc; + openDef = null; + keep(); + recalc(); + draw(); + }; + + /** 문서 열기 — 같은 키면 그대로(다시 그림만) */ + const open = async (next: string): Promise => { + if (next === key && doc) return; + key = next; + doc = answer = null; + given = {}; + openDef = null; + asked = ""; + cache = createDocCache(); + tables.clear(); + draw(); + if (!next) { + head.replaceChildren(el("p", { className: "ui-text ui-text--muted", text: ft("Need_Save") })); + return; + } + const mine = ++seq; + try { + const got = await call(`?key=${encodeURIComponent(next)}`); + if (mine !== seq || key !== next) return; + take(got); + fresh = !!got.새; + const draft = drafts()[next]; + if (draft) doc = clone(draft); + recalc(); + draw(); + } catch (e) { + if (mine === seq) + head.replaceChildren( + el("p", { + className: "ui-text ui-text--muted", + text: `${ft("Load_Failed")} — ${e instanceof Error ? e.message : ""}`, + }), + ); + } + }; + + const canLeave = async (): Promise => { + if (!dirty()) return true; + if (!(await showConfirmDialog(ft("Leave"), ft("Leave_Ok")))) return false; + doc = JSON.parse(saved) as FormulaDoc; + keep(); + return true; + }; + + return { + root, + /** 로직 키로 그림(빈 키 = 비움) — 다른 인자는 옛 수식 탭 꼴 호환 */ + draw: (next: string): void => void open(next), + canLeave, + /** 시험 · 확인용 — 지금 문서 · 셈 */ + state: () => ({ doc, answer, dirty: dirty() }), + }; +} diff --git a/A00_Common/formula_doc_text.ts b/A00_Common/formula_doc_text.ts new file mode 100644 index 000000000..f89442e22 --- /dev/null +++ b/A00_Common/formula_doc_text.ts @@ -0,0 +1,74 @@ +/* ============================================================================= + * formula_doc_text.ts + * 수식 문서 화면 글(PLAN 802-3) — [국문, 영문] + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; + +const TEXT = { + Sym_Head: ["기호 식", "Symbol equation"], + Val_Head: ["값 식", "Value equation"], + Defs_Head: ["기호 정의", "Symbols"], + Add_Line: ["+ 식 줄", "+ Line"], + Add_Money: ["+ 금액 줄", "+ Cost line"], + Add_Group: ["+ 묶음", "+ Group"], + New_Group: ["새 묶음", "New group"], + Unnamed: ["이름 없음", "Unnamed"], + Kind: ["비목", "Cost type"], + Drop_Line: ["줄 지움", "Delete line"], + Subtotal: ["소계", "Subtotal"], + Sum: ["계", "Sum"], + Total: ["합계", "Total"], + Need: ["정의 필요", "Needs definition"], + Unused: ["쓰지 않는 기호", "Unused symbols"], + Save: ["수식 저장", "Save equation"], + Discard: ["고침 버림", "Discard"], + Saved: ["수식 저장됨", "Equation saved"], + Save_Failed: ["저장 못 함", "Save failed"], + Load_Failed: ["수식 문서를 못 읽음", "Failed to load"], + Leave: ["저장 안 한 수식 고침이 있음 — 버리고 나갈까요?", "Discard unsaved edits?"], + Leave_Ok: ["버리고 나감", "Discard"], + Need_Save: [ + "새 로직 — [저장] 하면 빈 수식 문서에서 식을 씀", + "New logic — save it first, then write the equation", + ], + Fresh: ["수식 문서 없음 — 빈 문서에서 시작", "No equation yet — start from blank"], + Result: ["결과", "Result"], + Result_Sym: ["결과 기호", "Result symbol"], + Dirty: ["저장 안 한 고침", "Unsaved edits"], + Loading: ["불러오는 중", "Loading"], + Hint: [ + "식을 눌러 그 자리에서 고침 · 키보드 + − × ÷ / ( ) % · 첨자 Q_d · Q^d · Enter 넣음 · Esc 버림", + "Click to edit in place · Enter to apply · Esc to cancel", + ], + Sign: ["기호", "Symbol"], + Low: ["아래첨자", "Subscript"], + Up: ["위첨자", "Superscript"], + Name: ["국문 이름", "Name"], + Unit: ["단위", "Unit"], + Link: ["연결", "Link"], + Drop_Var: ["정의 지움", "Delete definition"], + Value: ["값", "Value"], + List: ["목록(· 로 나눔)", "Options (· separated)"], + Default: ["기본", "Default"], + Low_Bound: ["아래 끝", "Min"], + High_Bound: ["위 끝", "Max"], + Find: ["찾기", "Search"], + Column: ["칸", "Column"], + Cond: ["조건", "Condition"], + Inputs: ["넘길 입력", "Inputs"], + Region: ["지역 기호", "Region symbol"], + Make_Line: ["식 줄 만들기", "Add line"], + By_Line: ["식 줄로 정의", "Defined by a line"], + Pick_First: ["찾아 고름", "Search and pick"], + Material: ["재료 고르기", "Material pick"], + None: ["없음", "None"], + Open_Sub: ["식 펼침", "Show equation"], + Bad_Sign: ["기호 꼴 아님 · 같은 기호 있음", "Bad or duplicate symbol"], + More: ["더 있음 — 찾는 말을 더 넣으세요", "More — refine search"], + Nothing: ["찾은 것 없음", "Nothing found"], +} as const; + +export type TextKey = keyof typeof TEXT; + +export const ft = (key: TextKey): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; diff --git a/M01_MasterData/M01_MasterData_Doc.py b/M01_MasterData/M01_MasterData_Doc.py new file mode 100644 index 000000000..061820e76 --- /dev/null +++ b/M01_MasterData/M01_MasterData_Doc.py @@ -0,0 +1,145 @@ +"""M01 단가산출 기초 수식 문서 화면 서버 몫(PLAN 802-3 · 요구 664 · 요구서_664 3 장). + +문서 = `resources/master_data/수식문서/수식_<로직 파일 꼬리>.json`(`master_doc_link.FOLDER`) · +셈 = `master_doc_calc.calc`(파이썬 정본 · TS 짝 `common_util_formula_doc_calc.ts`) · +연결 기록 = `master_doc_record.Rec` — 셈이 쓴 요소 값 · 표 · 단가산출 문서를 화면 캐시로 실음 +(화면은 같은 계산기로 즉시 셈 · 캐시에 없는 연결만 뒤에서 미리 셈). +- 읽기 {키} → {문서, 셈, 연결, 파일, 새} — 문서가 없으면 빈 문서(이름 · 단위 = 로직 줄) +- 미리 셈 {문서, 입력} → {셈, 연결} — 쓰기 없음 +- 저장 {문서} → {문서, 셈, 연결, 파일} — 서버가 정본으로 다시 셈 · 문서 파일에 씀(브라우저 셈 값 안 받음) +권한 = 라우터를 `main.py` 가 `system_admin_only` 로 붙임. M02 는 같은 꼴로 따로(802-4). +""" + +from __future__ import annotations + +import json +from decimal import Decimal +from pathlib import Path + +from M01_MasterData import M01_MasterData_Store as store + +import master_doc as md # noqa: E402 — `store` 가 scripts 를 길에 둔 뒤 +import master_doc_calc as mc # noqa: E402 +import master_doc_link as ml # noqa: E402 +import master_doc_record as mr # noqa: E402 +import master_equation_link as link # noqa: E402 + +OWN_FILE = "수식_자체.json" +_memo: dict = {} + + +def folder() -> Path: + return ml.FOLDER + + +def _stamp() -> tuple: + return tuple((p.name, p.stat().st_mtime_ns) for p in sorted(folder().glob("수식_*.json"))) + + +def docs() -> dict[str, dict]: + """저장 문서 전부 {키: 문서} — 파일 시각이 같으면 다시 안 읽음.""" + stamp = (str(folder()), _stamp()) + if _memo.get("stamp") != stamp: + _memo.update(stamp=stamp, docs=ml.load_all(folder())) + return _memo["docs"] + + +def _dec(doc) -> dict: + """화면 JSON(float) → Decimal 문서 — 저장 문서와 같은 수 꼴로 셈.""" + return json.loads(json.dumps(doc, ensure_ascii=False), parse_float=Decimal, parse_int=Decimal) + + +def _where(key: str) -> str | None: + for path in sorted(folder().glob("수식_*.json")): + if any(str(d.get("키")) == key for d in ml.load_file(path).get("줄") or []): + return path.name + return None + + +def _run(doc: dict, given: dict | None = None) -> dict: + whole = store.loaded()[1] + every = docs() + own = str(doc.get("키") or "") + links = ml.Links(whole, {**every, own: doc} if own else every) + rec = mr.Rec(links) + rec.prefetch(doc) + answer = mc.calc(doc, rec, _dec(given or {})) + return {"셈": answer, "연결": rec.out()} + + +def _check(doc) -> dict: + if not isinstance(doc, dict) or doc.get("꼴") != "수식문서": + raise store.StoreError(400, "수식 문서 꼴 아님") + if not str(doc.get("키") or "").strip(): + raise store.StoreError(400, "문서 키 없음") + for k in ("변수", "식", "금액"): + if not isinstance(doc.get(k) or [], list): + raise store.StoreError(400, f"「{k}」 는 목록") + return _dec(doc) + + +def read(key: str) -> dict: + found = docs().get(key) + fresh = found is None + if fresh: + row = store.logic(key)["logic"] # 없는 키 = 404 + found = md.blank(key, str(row.get("이름") or ""), str(row.get("결과단위") or "원")) + out = _run(found) + return {"문서": found, **out, "파일": _where(key) or "", "새": fresh} + + +def preview(doc: dict, given: dict | None = None) -> dict: + return _run(_check(doc), given) + + +def _file_for(key: str) -> str: + """문서 파일 — 있던 파일 › 로직 파일 짝 › 자체.""" + have = _where(key) + if have: + return have + try: + return ml.file_of(store.logic(key)["file"]) + except store.StoreError: + return OWN_FILE + + +def save(doc: dict) -> dict: + doc = _check(doc) + key = str(doc["키"]).strip() + name = _file_for(key) + path = folder() / name + data = ml.load_file(path) if path.exists() else {"그룹": "수식문서", "로직파일": "", "줄": []} + rows = data.setdefault("줄", []) + at = next((i for i, d in enumerate(rows) if str(d.get("키")) == key), None) + if at is None: + rows.append(doc) + else: + rows[at] = doc + path.write_text(ml.dump(data), encoding="utf-8", newline="\n") + _memo.clear() + return {"문서": doc, **_run(doc), "파일": name} + + +def candidates(ask: dict) -> dict: + """연결 후보 — 요소 · 표 = M01 색인 찾기 · 단가산출 = 수식 문서 이름 찾기(읽기만).""" + if ask.get("갈래") != "단가산출": + return link.candidates(store.loaded()[1], ask) + words = str(ask.get("검색") or "").lower().split() + out = [] + for key, d in docs().items(): + text = f"{key} {d.get('이름') or ''}".lower() + if words and all(w in text for w in words): + ins = [ + str(v.get("열쇠")) + for v in d.get("변수") or [] + if (v.get("연결") or {}).get("갈래") in ("선택", "입력", "숫자") and v.get("열쇠") + ] + out.append({"키": key, "이름": d.get("이름"), "단위": d.get("결과단위"), "입력": ins}) + return {"후보": out[: link.LIMIT], "더있음": len(out) > link.LIMIT} + + +def table(key: str) -> dict: + """표 하나(조건 · 칸 고르기) — 기록 꼴 그대로.""" + rec = mr.Rec(ml.Links(store.loaded()[1], {})) + rec.see_table(key) + return rec.표[key] diff --git a/M01_MasterData/M01_MasterData_Router.py b/M01_MasterData/M01_MasterData_Router.py index e55251cb7..0715b9562 100644 --- a/M01_MasterData/M01_MasterData_Router.py +++ b/M01_MasterData/M01_MasterData_Router.py @@ -12,6 +12,7 @@ from typing import Any, Literal from fastapi import APIRouter, HTTPException from pydantic import BaseModel +from M01_MasterData import M01_MasterData_Doc as doc_screen from M01_MasterData import M01_MasterData_Equation as equation from M01_MasterData import M01_MasterData_Sheet as line_sheet from M01_MasterData import M01_MasterData_Store as store @@ -390,6 +391,42 @@ def post_logic_equation_save(body: EquationBody) -> dict: return _call(equation.save, body.key, body.고침, body.inputs, body.version, body.개정확인) +# ── 수식 문서(802-3) — 단가산출 기초 정본 · 화면은 같은 계산기로 즉시 셈 ── +class DocBody(BaseModel): + 문서: dict[str, Any] + 입력: dict[str, Any] = {} + + +@router.get("/logic/doc") +def get_logic_doc(key: str) -> dict: + """수식 문서 {문서, 셈, 연결, 파일, 새} — 없으면 빈 문서(쓰기 없음).""" + return _call(doc_screen.read, key) + + +@router.post("/logic/doc/preview") +def post_logic_doc_preview(body: DocBody) -> dict: + """미리 셈 {셈, 연결} — 화면 캐시에 없는 연결을 채움 · 쓰기 없음.""" + return _call(doc_screen.preview, body.문서, body.입력) + + +@router.post("/logic/doc/save") +def post_logic_doc_save(body: DocBody) -> dict: + """저장 — 서버가 정본으로 다시 셈 · 문서 파일에 씀 → {문서, 셈, 연결, 파일}.""" + return _call(doc_screen.save, body.문서) + + +@router.post("/logic/doc/links") +def post_logic_doc_links(body: LinksBody) -> dict: + """연결 후보 — 요소 · 표 · 단가산출(수식 문서) 이름 찾기(읽기만).""" + return _call(doc_screen.candidates, body.model_dump()) + + +@router.get("/logic/doc/table") +def get_logic_doc_table(key: str) -> dict: + """표 하나 — 조건 · 칸 고르기(읽기만).""" + return _call(doc_screen.table, key) + + # ── 일위대가 조합 — 조합 길은 M02 층 길(`/api/m02/layers/{layer}/combos` · PLAN 27-2a) ── @router.get("/logic/combos") def get_logic_combos(key: str) -> dict: diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab.ts b/M01_MasterData/M01_MasterData_UI_LogicLab.ts index 78a8c9c8d..a4037f5b3 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab.ts @@ -1,6 +1,6 @@ /* ============================================================================= * M01_MasterData_UI_LogicLab.ts - * 관리자 화면 — 로직 칸. 로직을 열면 두 칸 — 왼쪽 수식(796-2) · 단가산출 상세 / 오른쪽 원문 + * 관리자 화면 — 로직 칸. 로직을 열면 두 칸 — 왼쪽 수식(802-3 수식 문서 `A00_Common/formula_doc_screen.ts`) · 단가산출 상세 / 오른쪽 원문 * 셈 = 화면 칸 없이 구동(`_Logic_Drive.ts` · 799-2) — 열 때 첫 셈 · 입력 · 시험 고침 · 재료 고름이 `drive.run` * 로직 목록(원문·장 고르기 · 찾기 · 막힘 거름)은 왼쪽 패널 「단가산출 로직」 컨테이너(`listHost`) 안 * @@ -43,7 +43,7 @@ import { askBasicInfo } from "./M01_MasterData_UI_LogicLab_Basic"; import { saveRevised } from "./M01_MasterData_UI_LogicLab_Revise"; import { buildLogicSide } from "./M01_MasterData_UI_LogicLab_Side"; import { createViewPicker } from "./M01_MasterData_UI_LogicLab_View"; -import { createLabEquation } from "./M01_MasterData_UI_LogicLab_Equation"; +import { createDocScreen } from "../A00_Common/formula_doc_screen"; import { buildHeadBar } from "./M01_MasterData_UI_Head"; import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; import "./M01_MasterData_UI_Logic_Style.css"; @@ -107,14 +107,8 @@ export async function mountM01LogicLab( .catch(() => false); let recalc: number | undefined; const drive = createDrive(); - const equation = createLabEquation({ - admin, - blocked: () => !!opened && opened.id in drafts, // 시트 · 시험 쪽 저장 안 한 고침이 있으면 수식 고침 잠김 - onSaved: () => { - const was = opened; // reload 가 고름을 비우므로 먼저 기억 - if (was?.row?.키) void reload().then(() => open(was.id, was.sub, was.row!.키)); - }, - }); + // 802-3 수식 = 수식 문서 화면(공용 · 옛 수식 나무 화면은 M01 에서 걷음) + const equation = createDocScreen({ base: "/m01/logic/doc", admin, cache: "m01_doc_drafts" }); const editor = el("div", { className: "m01-logic__editor" }); // 로직 상세 — 왼쪽 지금 내용 · 오른쪽 접는 패널 「원문」 const panes = buildLogicSide(editor); @@ -250,7 +244,7 @@ export async function mountM01LogicLab( admin, equation: equation.root, }); - if (current.origKey) equation.draw(current.origKey, current.version, current.row as LogicRow); + equation.draw(current.origKey ?? ""); }; /** 단가산출 상세 옵션 입력 → 시험 입력(이름이 있는 칸만) · 바뀌면 다시 셈 */ diff --git a/common_util/common_util_formula_doc_cache.ts b/common_util/common_util_formula_doc_cache.ts new file mode 100644 index 000000000..792438dea --- /dev/null +++ b/common_util/common_util_formula_doc_cache.ts @@ -0,0 +1,103 @@ +/* ============================================================================= + * common_util_formula_doc_cache.ts + * 수식 문서 연결 캐시(PLAN 802-3) — 서버 기록(요소 값 · 표 · 단가산출 문서) → `DocLinks` + * 화면이 같은 계산기(`calcDoc`)로 네트워크 없이 셈 · 캐시에 없는 연결 = 빈 값(「불러오는 중」) + `missing` 표시 + * → 화면이 뒤에서 미리 셈(서버 기록)을 받아 `merge` 뒤 다시 셈 + * 파이썬 짝 = resources/master_data/scripts/master_doc_record.py(기록 꼴) · 화면 · DOM · fetch 없음 + * ========================================================================== */ + +import type { FormulaDoc } from "./common_util_formula_doc"; +import { Broken, Gap, plainText, type DocLinks } from "./common_util_formula_doc_calc"; +import { tableValue, type DocTable } from "./common_util_formula_doc_table"; + +type Payload = Record; +type Got = { 값?: unknown; 빈?: string; 끊김?: string }; + +export interface DocRecord { + 요소?: (Got & { p: Payload })[]; + 표?: Record; + 문서?: Record; + 이름?: Record; +} + +export const LOADING = "불러오는 중"; + +function canon(v: unknown): unknown { + if (typeof v === "number") return plainText(v); + if (Array.isArray(v)) return v.map(canon); + if (v && typeof v === "object") { + const out: Record = {}; + for (const k of Object.keys(v).sort()) out[k] = canon((v as Payload)[k]); + return out; + } + return v; +} +const keyOf = (p: Payload): string => JSON.stringify(canon(p)); + +export interface DocCache { + links: DocLinks; + /** 서버 기록을 더함 */ + merge: (rec: DocRecord | undefined) => void; + /** 키 → 이름(연결 글) */ + names: Map; + /** 마지막 셈에서 캐시에 없던 연결이 있었나 — 읽으면 지움 */ + takeMissing: () => boolean; + /** 표 하나 넣음(고르기에서 받은 것) */ + putTable: (ref: string, table: DocTable) => void; +} + +export function createDocCache(): DocCache { + const elements = new Map(); + const tables = new Map(); + const docs = new Map(); + const names = new Map(); + let missing = false; + const miss = (): never => { + missing = true; + throw new Gap(LOADING); + }; + const links: DocLinks = { + element(p) { + if (!p.키 && p.재료 === undefined) throw new Broken("요소 고르지 않음"); // 짝 master_doc_link + const got = elements.get(keyOf(p)); + if (!got) return miss(); + if (got.빈 !== undefined) throw new Gap(got.빈); + if (got.끊김 !== undefined) throw new Broken(got.끊김); + return got.값; + }, + table(p) { + if (!p.표 || !p.칸) throw new Broken("표 · 칸 고르지 않음"); + const ref = String(p.표); + if (!tables.has(ref)) return miss(); + const t = tables.get(ref); + if (!t || t.끊김 !== undefined) throw new Broken(t?.끊김 ?? `「${ref}」 표 아님`); + return tableValue(t, p); + }, + doc(key) { + if (!key) return null; + if (!docs.has(key)) return miss(); + return docs.get(key) ?? null; + }, + }; + return { + links, + names, + merge(rec) { + for (const one of rec?.요소 ?? []) { + const { p, ...got } = one; + elements.set(keyOf(p), got); + } + for (const [k, t] of Object.entries(rec?.표 ?? {})) tables.set(k, t); + for (const [k, d] of Object.entries(rec?.문서 ?? {})) docs.set(k, d); + for (const [k, n] of Object.entries(rec?.이름 ?? {})) names.set(k, n); + }, + takeMissing() { + const was = missing; + missing = false; + return was; + }, + putTable(ref, table) { + tables.set(ref, table); + }, + }; +} diff --git a/common_util/common_util_formula_doc_calc.ts b/common_util/common_util_formula_doc_calc.ts index 4ff30a87a..5ddcad3f1 100644 --- a/common_util/common_util_formula_doc_calc.ts +++ b/common_util/common_util_formula_doc_calc.ts @@ -353,7 +353,8 @@ class Run { } sub(p: Payload, name: string): Val { - const key = String(p.키); + const key = String(p.키 ?? ""); + if (!key) throw new Broken("단가산출 고르지 않음"); // 고르는 중 — 표지 · 짝 파이썬 sub if (this.stack.includes(key) || this.stack.length > DEEP) return new Stop(`단가산출 부름 돌고 돎 — ${[...this.stack, key].join(" → ")}`, "순환", name); const doc = this.links.doc(key); diff --git a/resources/master_data/scripts/master_doc_calc.py b/resources/master_data/scripts/master_doc_calc.py index 41ac3da0a..e17bb8ac5 100644 --- a/resources/master_data/scripts/master_doc_calc.py +++ b/resources/master_data/scripts/master_doc_calc.py @@ -285,7 +285,9 @@ class _Run: return Stop(f"「{name}」 연결 갈래 「{kind}」", "연결 꼴", name) def sub(self, payload: dict, name: str): - key = payload["키"] + key = str(payload.get("키") or "") + if not key: # 고르는 중 — 표지(예외 아님) · 짝 TS sub + raise Broken("단가산출 고르지 않음") if key in self.stack or len(self.stack) > DEEP: return Stop(f"단가산출 부름 돌고 돎 — {' → '.join([*self.stack, key])}", "순환", name) doc = self.links.doc(key) diff --git a/resources/master_data/scripts/master_doc_link.py b/resources/master_data/scripts/master_doc_link.py index cbbc06d4f..9d569e6a9 100644 --- a/resources/master_data/scripts/master_doc_link.py +++ b/resources/master_data/scripts/master_doc_link.py @@ -122,6 +122,8 @@ class Links: raise mc.Broken(str(e)) from None def table(self, p: dict): + if not p.get("표") or not p.get("칸"): # 고르는 중 — 표지(예외 아님) + raise mc.Broken("표 · 칸 고르지 않음") table = self._get(p["표"]) if "줄" not in table or "조건" not in table: raise mc.Broken(f"「{p['표']}」 표 아님") @@ -135,6 +137,8 @@ class Links: return _cell(row[p["칸"]], p["칸"]) def element(self, p: dict): + if not p.get("키") and p.get("재료") is None: # 고르는 중 — 표지(예외 아님) + raise mc.Broken("요소 고르지 않음") env = {} if p.get("지역") is not None: env[mm.REGION] = p["지역"] diff --git a/resources/master_data/scripts/master_doc_record.py b/resources/master_data/scripts/master_doc_record.py new file mode 100644 index 000000000..49416118c --- /dev/null +++ b/resources/master_data/scripts/master_doc_record.py @@ -0,0 +1,102 @@ +# -*- coding: utf-8 -*- +"""수식 문서 연결 기록 — 화면 캐시로 싣는 연결 값(PLAN 802-3 · 꼴 `_수식문서_틀.md`). + +서버 셈(`master_doc_calc.calc`)이 쓴 요소 값 · 표 · 단가산출 문서를 적어 화면에 실음 — +브라우저가 같은 계산기(`common_util_formula_doc_calc.ts`)로 네트워크 없이 다시 셈. +화면 짝 = `common_util/common_util_formula_doc_cache.ts`(기록 → `DocLinks`). +- 요소 = 부른 꼴(payload) 그대로 + {값 | 빈 | 끊김} +- 표 = 표 통째(조건 · 값칸 · 줄) — 옵션 · 입력이 바뀌어도 화면이 줄을 찾음 · 표 아님 = {끊김} +- 단가산출 = 문서 통째(없으면 null) +`prefetch` = 식 · 옵션과 무관하게 글로 적힌 표 · 단가산출 키를 미리 담음(구멍 `{…}` 든 키는 셈 때만). +""" + +from __future__ import annotations + +import master_doc_calc as mc +import master_formula as mf + + +class Rec: + """`Links` 를 감싸 부른 연결을 적음 — `out()` = 화면 캐시.""" + + def __init__(self, links): + self.links = links + self.요소: dict[str, dict] = {} + self.표: dict[str, dict | None] = {} + self.문서: dict[str, dict | None] = {} + self.이름: dict[str, str] = {} + + def element(self, p: dict): + key = repr(sorted(p.items(), key=lambda kv: kv[0])) + try: + got = self.links.element(p) + except mc.Gap as e: + self.요소[key] = {"p": p, "빈": str(e)} + raise + except mc.Broken as e: + self.요소[key] = {"p": p, "끊김": str(e)} + raise + self.요소[key] = {"p": p, "값": got} + return got + + def see_table(self, ref: str) -> None: + if ref in self.표: + return + try: + table = self.links.master.get(ref) + except mf.FormulaError as e: + self.표[ref] = {"끊김": str(e)} + return + if "줄" not in table or "조건" not in table: + self.표[ref] = {"끊김": f"「{ref}」 표 아님"} + return + keep = ("키", "원문번호", "이름", "조건", "범위규칙", "값칸", "줄") + self.표[ref] = {k: table[k] for k in keep if k in table} + + def table(self, p: dict): + self.see_table(str(p.get("표"))) + return self.links.table(p) + + def doc(self, key: str): + got = self.links.doc(key) + self.문서[key] = got + return got + + def name(self, ref: str) -> None: + """키 → 이름(화면 연결 글 · DB 키는 안 보임).""" + if not ref or "{" in ref or ref in self.이름: + return + doc = self.links.docs.get(ref) + if doc is not None: + self.이름[ref] = str(doc.get("이름") or "") + return + try: + row = self.links.master.get(ref) + except mf.FormulaError: + return + self.이름[ref] = " ".join(str(x) for x in (row.get("이름"), row.get("규격")) if x) + + def prefetch(self, doc: dict, seen: set | None = None) -> None: + seen = set() if seen is None else seen + for v in doc.get("변수") or []: + link = v.get("연결") or {} + kind, ref = link.get("갈래"), str(link.get("키") or link.get("표") or "") + if kind in ("표", "요소", "단가산출"): + self.name(ref) + if not ref or "{" in ref: + continue + if kind == "표": + self.see_table(ref) + elif kind == "단가산출" and ref not in seen: + seen.add(ref) + sub = self.doc(ref) + if sub: + self.prefetch(sub, seen) + + def out(self) -> dict: + return { + "요소": list(self.요소.values()), + "표": self.표, + "문서": self.문서, + "이름": self.이름, + } diff --git a/resources/tester/test_802_3_doc_screen.py b/resources/tester/test_802_3_doc_screen.py new file mode 100644 index 000000000..e0bf58676 --- /dev/null +++ b/resources/tester/test_802_3_doc_screen.py @@ -0,0 +1,206 @@ +# -*- coding: utf-8 -*- +"""PLAN 802-3 — M01 단가산출 기초 수식 문서 화면 서버 길(요구 660~665 · 요구서_664). + +① 읽기 {문서, 셈, 연결} · 없는 문서 = 빈 문서 · 없는 키 404 +② 미리 셈 = 쓰기 없음 · 연결 기록(표 통째 · 단가산출 문서 · 요소 값) +③ 저장 = 서버가 정본으로 다시 셈(보낸 셈 값 무시) · 임시 폴더 파일에만 씀 · 다시 읽어 같음 +④ 시스템 관리자 아니면 막힘(main.py 와 같은 의존성) +⑤ 지움 견딤 — 기호 · 줄 · 금액 줄 · 연결 대상 지움 · 순환 · 0 나눔 · 목록 밖 · 전부 지움 = 표지 · 금액 없음(500 없음) +""" + +from __future__ import annotations + +import copy +import shutil +from pathlib import Path + +import pytest +from fastapi import Depends, FastAPI +from fastapi.testclient import TestClient + +from common_util import common_util_auth as auth +from M01_MasterData import M01_MasterData_Doc as doc_screen +from M01_MasterData import M01_MasterData_Router as router_module + +ROOT = Path(__file__).resolve().parents[2] +KEY = "GC000211" # 합판거푸집 설치 및 해체 — 계 124,419 + + +@pytest.fixture +def client(tmp_path: Path, monkeypatch) -> TestClient: + for path in doc_screen.folder().glob("수식_*.json"): + shutil.copy(path, tmp_path / path.name) + monkeypatch.setattr(doc_screen, "folder", lambda: tmp_path) + doc_screen._memo.clear() + app = FastAPI() + app.include_router(router_module.router) + yield TestClient(app) + doc_screen._memo.clear() + + +def _read(c: TestClient, key: str = KEY) -> dict: + res = c.get("/api/m01/logic/doc", params={"key": key}) + assert res.status_code == 200, res.text + return res.json() + + +def _prev(c: TestClient, doc: dict, given: dict | None = None) -> dict: + res = c.post("/api/m01/logic/doc/preview", json={"문서": doc, "입력": given or {}}) + assert res.status_code == 200, res.text + return res.json() + + +def test_읽기_미리셈_연결기록(client): + got = _read(client) + assert got["셈"]["계"] == 124419 and got["셈"]["머리"] == "계" and got["새"] is False + rec = got["연결"] + assert {"QC000428", "QC000429", "QC000431"} <= set( + rec["표"] + ) # 표 통째(옵션 바뀌어도 화면이 찾음) + assert all("줄" in t for t in rec["표"].values() if t) + assert any(e["p"].get("키") == "LB000007" and "값" in e for e in rec["요소"]) + assert rec["이름"]["QC000431"].startswith("합판거푸집") + # 옵션(입력) 바꿈 = 문서 안 바뀜 · 값만 + other = _prev(client, got["문서"], {"폼타이": "사용", "측압": "4t/㎡"}) + assert got["셈"]["값"]["Q_조"] == 0 + assert other["셈"]["값"]["Q_조"] == other["셈"]["값"]["Q_4t^표"] > 0 + + +def test_없는문서_빈문서_없는키_404(client): + fresh = _read(client, "GC000100") # 802-2a 로 문서 안 넣음 + assert fresh["새"] is True and fresh["문서"]["식"] == [] and fresh["문서"]["금액"] == [] + assert fresh["셈"]["셈됨"] is False and fresh["셈"]["계"] is None + assert client.get("/api/m01/logic/doc", params={"key": "GX999999"}).status_code == 404 + + +def test_저장_서버가_다시셈_파일에씀(client, tmp_path): + doc = _read(client)["문서"] + doc["식"].append({"기호": "Z_시험", "식": "3600 × 2 ÷ 4"}) + doc["셈"] = {"계": 1} # 브라우저 값 — 서버는 안 받음 + res = client.post("/api/m01/logic/doc/save", json={"문서": doc}) + assert res.status_code == 200, res.text + out = res.json() + assert out["셈"]["값"]["Z_시험"] == 1800 and out["셈"]["계"] == 124419 + assert out["파일"] == "수식_건설품셈_06장_철근콘크리트공사.json" + assert "Z_시험" in (tmp_path / out["파일"]).read_text(encoding="utf-8") + again = _read(client) + assert again["문서"]["식"][-1] == {"기호": "Z_시험", "식": "3600 × 2 ÷ 4"} + assert again["셈"]["계"] == 124419 + # 정본 폴더는 안 바뀜 + real = (ROOT / "resources/master_data/수식문서" / out["파일"]).read_text(encoding="utf-8") + assert "Z_시험" not in real + # 꼴 아님 · 키 없음 = 400 + assert client.post("/api/m01/logic/doc/save", json={"문서": {"꼴": "x"}}).status_code == 400 + assert ( + client.post("/api/m01/logic/doc/save", json={"문서": {"꼴": "수식문서"}}).status_code == 400 + ) + + +def test_새문서_저장_빈데서_시작(client, tmp_path): + blank = _read(client, "GC000100")["문서"] + blank["식"] = [{"기호": "Q", "식": "3600 × q ÷ C_m"}] + blank["변수"] = [ + {"열쇠": "q", "기호": "q", "연결": {"갈래": "숫자", "값": 0.7}}, + {"열쇠": "C_m", "기호": "C", "아래": "m", "연결": {"갈래": "숫자", "값": 18}}, + {"열쇠": "w", "기호": "w", "연결": {"갈래": "요소", "키": "LB000002"}}, + ] + blank["금액"] = [{"이름": "보통인부", "비목": "노무비", "단가": "w ÷ Q"}] + out = client.post("/api/m01/logic/doc/save", json={"문서": blank}).json() + assert out["셈"]["셈됨"] and out["셈"]["값"]["Q"] == 140 + assert _read(client, "GC000100")["새"] is False + + +def test_관리자_아니면_막힘(monkeypatch): + for role, code in (("USER", 403), ("COMPANY_ADMIN", 403), ("SYSTEM_ADMIN", 200)): + app = FastAPI() + app.include_router( + router_module.router, + dependencies=[Depends(auth.verify_session), Depends(auth.require_system_admin)], + ) + app.dependency_overrides[auth.verify_session] = lambda role=role: { + "role": role, + "is_master": False, + "company_id": 1, + } + c = TestClient(app) + assert c.get("/api/m01/logic/doc", params={"key": KEY}).status_code == code + if code == 403: + res = c.post("/api/m01/logic/doc/save", json={"문서": {"꼴": "수식문서", "키": KEY}}) + assert res.status_code == 403 + main = (ROOT / "main.py").read_text(encoding="utf-8") + assert "app.include_router(m01_master_data_router, dependencies=system_admin_only)" in main + + +def _cuts(doc: dict): + """지운 · 망가뜨린 문서들.""" + for i in range(len(doc["식"])): + one = copy.deepcopy(doc) + del one["식"][i] # 줄 하나 지움 + yield one + one = copy.deepcopy(doc) + one["식"][i]["식"] = "없는기호 × 2" # 없는 기호 + yield one + for i in range(len(doc["금액"])): + one = copy.deepcopy(doc) + del one["금액"][i] + yield one + for i in range(len(doc["변수"])): + one = copy.deepcopy(doc) + del one["변수"][i] # 기호 하나 지움 → 정의 필요 + yield one + one = copy.deepcopy(doc) + kind = one["변수"][i]["연결"]["갈래"] + one["변수"][i]["연결"] = {"갈래": kind} # 연결 대상 아직 안 고름 + yield one + one = copy.deepcopy(doc) + one["식"].append({"기호": "Q_시공^표", "식": "Q_시공 × 2"}) # 겹침 + yield one + one = copy.deepcopy(doc) + for v in one["변수"]: + if v["열쇠"] == "유형": + v["연결"]["목록"] = ["보통"] # 목록 밖 + yield one + one = copy.deepcopy(doc) + one["변수"], one["식"], one["금액"] = [], [], [] # 전부 지움 + yield one + + +def test_지움_견딤_표지_금액없음(client): + doc = _read(client)["문서"] + n = 0 + for one in _cuts(doc): + got = _prev(client, one)["셈"] + n += 1 + if not got["셈됨"]: + assert got["계"] is None and (got["표지"] or got.get("까닭")) + assert n > 80 + + +@pytest.mark.parametrize( + "line, kind", + [ + ({"기호": "Q_시공", "식": "Q_형틀목공 × 2"}, "순환"), + ({"기호": "Q_시공", "식": "Q_시공^표 ÷ 0"}, "0 나눔"), + ({"기호": "Q_시공", "식": "Q_시공^표 × ("}, "식 틀림"), + ({"기호": "Q_시공", "식": "Z_없음 × 2"}, "정의 필요"), + ], +) +def test_표지_갈래(client, line, kind): + doc = _read(client)["문서"] + doc["식"] = [line if x["기호"] == line["기호"] else x for x in doc["식"]] + got = _prev(client, doc)["셈"] + assert not got["셈됨"] and got["계"] is None + assert kind in {m["갈래"] for m in got["표지"]} + + +def test_연결_후보_표(client): + res = client.post( + "/api/m01/logic/doc/links", json={"갈래": "단가산출", "검색": "굴착기 시간당"} + ) + hits = res.json()["후보"] + assert hits and all("입력" in h for h in hits) + res = client.post("/api/m01/logic/doc/links", json={"갈래": "표", "검색": "토사깍기 기계"}) + assert any(h["키"] == "QF000249" for h in res.json()["후보"]) + table = client.get("/api/m01/logic/doc/table", params={"key": "QF000249"}).json() + assert "조건" in table and table["줄"] + assert "끊김" in client.get("/api/m01/logic/doc/table", params={"key": "LB000002"}).json()