feat(802-3): M01 단가산출 기초 수식 문서 화면 — 왼쪽 기호 식 · 오른쪽 값 식 · 빈 문서 시작 · 연결 고르기

- 공용 화면 A00_Common/formula_doc_*(M01 · 뒤에 M02 = 길 앞머리만 바꿈) — 옛 수식 나무 화면은 M01 에서 걷음(M02 는 그대로)
- 보이는 수식 그 자리 고침(공용 editEq · 전체 고르기 = 한 줄 글) · 분수 · 첨자 · 만약 = 탄 갈래만 · 반올림 자리 숨김 · 값 식 단위
- 값 식 숫자 누름 = 그 자리 값 · 기호 값 누름 = 정의 줄 · 정의 영역 = 기호 : 이름 — 연결(키 · 값 · 단위 없음 · 탄 갈래로 닿는 기호만)
- 새 기호 = 정의 필요 → 숫자 · 선택 · 입력 · 요소 · 표 · 단가산출 · 식 연결 · 기호 · 첨자 · 이름 고침 · 정의 지움
- 금액 = 경쟁사 꼴 이름 : 단가 × 수량 = 금액 · 묶음 하나 = 계 · 둘 이상 = 가. 이름 소계 + 합계 · 단가산출 연결 펼침 = 그 문서 식 · 값 식
- 셈 = 브라우저 계산기 즉시 · 연결 값 = 서버 기록 캐시(common_util_formula_doc_cache.ts ↔ master_doc_record.py) · 없는 연결만 뒤에서 미리 셈
- 서버 길 GET /api/m01/logic/doc · POST preview · save(서버가 정본으로 다시 셈 · 수식 문서 파일에 씀) · links · GET table (M01_MasterData_Doc.py)
- 연결 대상 안 고른 연결(표 · 요소 · 단가산출) = 연결 끊김 표지(파이썬 · TS 짝 가드 — 서버 500 걷음)
- 시험 test_802_3_doc_screen 11(읽기 · 빈 문서 · 저장 = 서버 다시 셈 · 관리자 아니면 막힘 · 지움 견딤 · 표지 갈래 · 후보)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 19:23:57 +09:00
co-authored by Claude Opus 5.5
parent d51c0b2200
commit 54fb55487c
15 changed files with 2799 additions and 13 deletions
+309
View File
@@ -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;
}
+372
View File
@@ -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<string, DocVar>;
names: Map<string, string>;
lines: Set<string>;
shown: string[];
unused: string[];
need: Set<string>;
open: string | null;
onOpen: (id: string | null) => void;
setLink: (id: string, link: Link) => void;
setVar: (id: string, patch: Partial<DocVar>) => 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<DocTable | null>;
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<string, string>,
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<string, unknown>;
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<void> => {
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<Link>): 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;
}
+386
View File
@@ -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<string, DocVar>;
/** 기호 · 금액 줄 이름 → 표지 글 */
marks: Map<string, string>;
auto: EditAt;
onLine: (i: number, text: string) => void;
onExpr: (i: number, text: string) => void;
onMoney: (i: number, patch: Partial<MoneyLine> | 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<DocAnswer, Values>();
/** 기호 값 + 금액 줄 기호(그 줄 금액 — 뒤 금액 줄이 부름) */
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<HTMLElement>("[data-lit]");
const atom = t.closest<HTMLElement>("[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<HTMLElement>(".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<string, number>; 금액?: 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<string, unknown> | 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<string, unknown>[];
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<string, unknown>) : 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;
};
+373
View File
@@ -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<string, unknown>;
const HIDE = new Set(["반올림", "올림", "버림"]);
const SHOW: Record<string, string> = {
"*": "×",
"/": "÷",
"-": "−",
"<=": "≤",
">=": "≥",
"!=": "≠",
};
const PREC: Record<string, number> = {
또는: 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<string, DocVar>;
/** ÷ 를 분수 대신 한 줄로(금액 줄 · 경쟁사 꼴) */
inline?: boolean;
/** 묶음 값의 비목(금액 묶음 줄 노 · 재 · 경 줄) */
cost?: string;
/** 수 마디 → 차례(값 식 숫자 고침) */
lits?: Map<Node, number>;
}
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<Node, number> {
const out = new Map<Node, number>();
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;
}
+678
View File
@@ -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 = <T>(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<string, unknown> = {};
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<string, Promise<DocTable | null>>();
const call = async <T>(path: string, body?: unknown, signal?: AbortSignal): Promise<T> => {
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<string, FormulaDoc> => {
try {
return JSON.parse(sessionStorage.getItem(opt.cache) ?? "{}") as Record<string, FormulaDoc>;
} 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<string, unknown> => {
const out: Record<string, unknown> = {};
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<MoneyLine> | 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<string, unknown>)[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<DocTable | null> => {
if (!tables.has(k))
tables.set(
k,
call<DocTable & { 끊김?: string }>(`/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<string, unknown> = {};
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<string, number>();
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<string, string>();
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<void> => {
if (!doc) return;
showLoadingOverlay();
try {
const got = await call<Loaded>("/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<void> => {
if (!doc || !dirty()) return;
doc = JSON.parse(saved) as FormulaDoc;
openDef = null;
keep();
recalc();
draw();
};
/** 문서 열기 — 같은 키면 그대로(다시 그림만) */
const open = async (next: string): Promise<void> => {
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<Loaded>(`?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<boolean> => {
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() }),
};
}
+74
View File
@@ -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];
+145
View File
@@ -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]
+37
View File
@@ -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:
+5 -11
View File
@@ -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 ?? "");
};
/** 단가산출 상세 옵션 입력 → 시험 입력(이름이 있는 칸만) · 바뀌면 다시 셈 */
@@ -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<string, unknown>;
type Got = { 값?: unknown; 빈?: string; 끊김?: string };
export interface DocRecord {
요소?: (Got & { p: Payload })[];
표?: Record<string, (DocTable & { 끊김?: string }) | null>;
문서?: Record<string, FormulaDoc | null>;
이름?: Record<string, string>;
}
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<string, unknown> = {};
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<string, string>;
/** 마지막 셈에서 캐시에 없던 연결이 있었나 — 읽으면 지움 */
takeMissing: () => boolean;
/** 표 하나 넣음(고르기에서 받은 것) */
putTable: (ref: string, table: DocTable) => void;
}
export function createDocCache(): DocCache {
const elements = new Map<string, Got>();
const tables = new Map<string, (DocTable & { 끊김?: string }) | null>();
const docs = new Map<string, FormulaDoc | null>();
const names = new Map<string, string>();
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);
},
};
}
+2 -1
View File
@@ -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);
@@ -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)
@@ -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["지역"]
@@ -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.이름,
}
+206
View File
@@ -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()