feat(802-4b-2): M02 단가산출 수식 탭 = 공용 수식 문서 화면 · 꺼진 줄 = 계산 변수 거침

- M02 SK 「수식」 탭 = M01 과 같은 공용 화면(`createDocScreen` · 길 앞머리만 `/m02/layers/{층}/sangeun/{키}/doc`) — 옛 M02 수식 나무 화면(`_Sangeun_Equation.ts` · 796-2b · 2c · 2e · 800 · 801 꼴) 걷음
  · 옵션 = SK 문서 값(저장) · [단가 갱신] · [재생성](미리 셈 → 알림 계 전 → 후 · 버리는 것 → 저장) · 「M01 새 판 있음」 배지(`formula_doc_copy.ts`)
  · 단가산출 연결 펼침 머리 「연결 / 끊음」 · [연결 끊기] · [다시 잇기] · 끊은 문서 안 줄 그 자리 고침(`formula_doc_sub.ts`) · 저장 몸 {문서, 끊음, 판}
  · 층 쓰기 권한 없음 = 읽기만(옵션 잠금 · 단추 없음 · 「읽기만」) · 판 바뀜 409 = 사본 같으면 새 판으로 한 번 더 · 저장 뒤 페이지 판 · 상세 탭 `수식` 칸 새로
  · 저장 안 한 SK(일위대가 줄 모달) = 「저장한 단가산출만」 글
- 서버 — 수식 길 답에 `연결`(화면 캐시 기록 · `M02_Doc_Screen.record`) · 연결 후보 · 표 길(층 읽기 · M01 후보 · 표 그대로 · main 등록)
- 꺼진 줄(요구 663-1 마저) — 곱 인수 하나가 선택으로 꺼진 0(만약 0 갈래 · 식 줄 기호면 그 식으로 거침)이면 숨김 · 입력 · 글 0 은 그대로
  · 판정을 순수 파일로(`common_util_formula_doc_view.ts` · Node 로 시험) · 1,518 문서 기본 옵션 꺼짐 158 → 203 줄(꺼진 줄 금액 모두 0) · GC000211 폼타이 아님 = 세 규격 줄 모두
- 화면 파일 700 줄 — 고침 함수 · 옵션 가로 · 연결 펼침을 새 파일로(`formula_doc_edit` · `_options` · `_sub`)
- 시험 test_802_4b2(5 · 헬퍼 helper_802_4b2_off.cjs) · 옛 시험 둘 고침 — test_793_3b · test_796_2b 가 걷은 옛 M02 수식 화면 글을 읽음 → 없음 확인으로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 20:20:52 +09:00
co-authored by Claude Opus 5.5
parent c07cd1d133
commit c80b5ae9d0
21 changed files with 1146 additions and 592 deletions
+89
View File
@@ -0,0 +1,89 @@
/* =============================================================================
* formula_doc_copy.ts
* 수식 문서 사본 몫(PLAN 802-4b-2 · 요구 655-2 · 656 · 659-3) — M02 단가산출(SK) 수식 탭 머리
* 「M01 새 판 있음」 배지(값 줄 · 부름 문서가 지금 M01 과 다름) · [단가 갱신](자원 단가만 · 식 그대로) ·
* [재생성](지금 M01 문서로 다시 찍음 · 버리는 것 알림) — 누를 때만(자동 갱신 없음)
* 흐름 = 미리 셈(`{길}/preview` {할일}) → 확인 창(계 전 → 후 · 바뀐 자원 · 버리는 것) → 저장(`{길}/save` {할일, 판})
* 층 쓰기 권한 없으면 「읽기만」 배지
* ========================================================================== */
import { createBadge } from "@ui/ui_template_badge";
import { createButton } from "@ui/ui_template_button";
import { eqText } from "@ui/ui_template_equation";
import { showConfirmDialog } from "@ui/ui_template_elements";
import { ft } from "./formula_doc_text";
export type CopyJob = "단가갱신" | "재생성";
/** 사본 표지(서버 `M02_Doc_Copy.state` + 시점 · 따로) */
export interface CopyMark {
M01새판?: boolean;
지운키?: string[];
단가새값?: boolean;
문서새판?: string[];
따로?: boolean;
}
export interface CopyOptions {
/** SK 키 · 꼬리(`""` · `/preview` · `/table?key=…`) → 길(`/m02/layers/{층}/sangeun/{키}/doc…` · 주인 물음) */
route: (key: string, path: string) => string;
/** 저장 뒤 그 SK 새 판(페이지 [저장] 409 막음) */
onSaved?: (version: string) => void;
}
type Money = Record<string, number | null> | null;
export interface JobReply {
전?: Money;
후?: Money;
바뀐자원?: unknown[];
버림?: { 문서고침?: boolean; 끊음?: string[] };
}
/** 「M01 새 판 있음」 — 값 줄 · 단가 · 부름 문서 어느 하나라도 지금 M01 과 다름 */
export function isStale(m: CopyMark | undefined): boolean {
return !!m && (!!m.M01새판 || !!m.단가새값 || !!m.지운키?.length || !!m.문서새판?.length);
}
export function copyBadges(m: CopyMark | undefined, edit: boolean): HTMLElement[] {
const out: HTMLElement[] = [];
if (isStale(m)) {
const b = createBadge(ft("New_M01"), "danger");
b.dataset.copyStale = "";
out.push(b);
}
if (!edit) out.push(createBadge(ft("Read_Only"), "neutral"));
return out;
}
export function copyTools(edit: boolean, run: (job: CopyJob) => void): HTMLElement[] {
if (!edit) return [];
return (["단가갱신", "재생성"] as const).map((job) => {
const b = createButton({
label: job === "단가갱신" ? ft("Price") : ft("Regen"),
variant: "ghost",
size: 28,
onClick: () => run(job),
});
b.dataset.copyJob = job;
return b;
});
}
const sum = (m: Money | undefined): string =>
typeof m?.계 === "number" ? `${eqText(m.계)} 원` : "—";
/** 할일 알림 — 계 전 → 후 · 바뀐 자원 수 · 재생성이 버리는 것(이름) → 확인이면 true */
export function askJob(job: CopyJob, got: JobReply, names: Map<string, string>): Promise<boolean> {
const bits = [
job === "단가갱신" ? ft("Price_Ask") : ft("Regen_Ask"),
`${ft("Before_After")} ${sum(got.전)} → ${sum(got.후)}`,
];
if (job === "단가갱신") bits.push(`${ft("Changed")} ${got.바뀐자원?.length ?? 0}`);
const lost = [
...(got.버림?.문서고침 ? [ft("Regen_Edit")] : []),
...(got.버림?.끊음 ?? []).map((k) => `${ft("Cut")} ${names.get(k) || k}`),
];
if (job === "재생성" && lost.length) bits.push(`${ft("Regen_Lost")} — ${lost.join(" · ")}`);
return showConfirmDialog(bits.join(" · "), job === "단가갱신" ? ft("Price") : ft("Regen"));
}
+92
View File
@@ -0,0 +1,92 @@
/* =============================================================================
* formula_doc_edit.ts
* 수식 문서 고침(PLAN 802-3 · 802-4b-2) — 식 줄 · 금액 줄 · 값 식 숫자 고침을 문서에 얹음(화면 · fetch 없음)
* 수식 화면(`formula_doc_screen.ts`)과 끊은 단가산출 블록(`formula_doc_sub.ts`)이 같은 고침을 씀
* ========================================================================== */
import {
goodId,
splitId,
tidy,
varId,
type DocVar,
type FormulaDoc,
type MoneyLine,
} from "../common_util/common_util_formula_doc";
import { swapLit } from "./formula_doc_render";
/** 머리 = 식 — 첫 「=」(비교 ==, <=, >=, != 빼고) */
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 const tidyText = (t: string): string => (t.trim() ? tidy(t.trim()) : "");
/** 기호 정의 — 없으면 「식」 갈래로 새로 */
export function ensureVar(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;
}
/** 식 줄 i 를 글로 — 빈 글 = 줄 지움 · 머리 바뀜 = 식 갈래 정의도 같이 */
export function editLine(d: FormulaDoc, i: number, text: string): void {
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);
}
/** 금액 줄 i 고침(null = 지움) — 단가 · 수량 글은 다듬음 · 빈 수량 = 칸 뺌 */
export function editMoney(d: FormulaDoc, i: number, patch: Partial<MoneyLine> | null): void {
const lines = (d.금액 ??= []);
if (!patch) {
lines.splice(i, 1);
return;
}
const line = lines[i];
if (!line) return;
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;
}
}
/** 값 식 숫자 고침 — 식 줄 · 금액 줄 단가 · 수량 글의 at 번째 수 */
export function editLit(
d: FormulaDoc,
target: "line" | "price" | "qty",
i: number,
at: number,
value: number,
): void {
if (target === "line") {
const line = d.식?.[i];
if (line) line.식 = swapLit(String(line.식 ?? ""), at, value);
return;
}
const line = d.금액?.[i];
if (!line) return;
const slot = target === "price" ? "단가" : "수량";
line[slot] = swapLit(String(line[slot] ?? ""), at, value);
}
+3 -1
View File
@@ -16,6 +16,7 @@ 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 {
lineMap,
litsOf,
offLine,
renderNode,
@@ -345,7 +346,8 @@ export function buildLines(h: LinesHooks): HTMLElement[] {
const lines = h.doc.금액 ?? [];
const groups: string[] = [];
lines.forEach((l) => groups.includes(l.묶음 ?? "") || groups.push(l.묶음 ?? ""));
const off = new Set(lines.flatMap((l, i) => (offLine(l, vals(h)) ? [i] : [])));
const steps = lineMap(h.doc);
const off = new Set(lines.flatMap((l, i) => (offLine(l, vals(h), steps) ? [i] : [])));
const seen = (i: number): boolean => !!h.showOff || !off.has(i);
const many = groups.length > 1;
const bundles = (h.answer.묶음 ?? []) as Record<string, unknown>[];
+55
View File
@@ -0,0 +1,55 @@
/* =============================================================================
* formula_doc_options.ts
* 수식 문서 옵션 가로(PLAN 802-3 · 802-4b-2 · 요구 655 「옵션 선택은 상단에 수평」) — 선택 · 입력 기호마다 칸 하나
* 값 = 화면 값(`given`) › 문서 값 · 고침은 부른 쪽 `set`(M01 = 화면 값만 · M02 사본 = 문서 값) · `lock` = 읽기만
* ========================================================================== */
import { createInputField, createSelectField } from "@ui/ui_template_input";
import { varId, type DocVar, type FormulaDoc } from "../common_util/common_util_formula_doc";
export function buildOptions(
doc: FormulaDoc | null,
given: Record<string, unknown>,
lock: boolean,
set: (key: string, v: DocVar, value: unknown) => void,
): 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.연결?.값;
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,
disabled: lock,
onChange: (x) => set(k, v, 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.disabled = lock;
f.input.addEventListener("change", () => {
const t = f.input.value.trim();
const n = Number(t.replace(/,/g, ""));
set(k, v, t !== "" && Number.isFinite(n) ? n : t);
});
f.root.dataset.option = k;
out.push(f.root);
}
}
return out;
}
+19 -93
View File
@@ -10,9 +10,7 @@
import { eqText, symbolOf } from "@ui/ui_template_equation";
import {
DocError,
linkNames,
parse,
splitId,
textOf,
varId,
@@ -20,9 +18,25 @@ import {
type FormulaDoc,
type Node,
} from "../common_util/common_util_formula_doc";
import { binary, cut, isNum } from "../common_util/common_util_formula_doc_calc";
import { isNum } from "../common_util/common_util_formula_doc_calc";
import {
leafNum,
offLine,
takenOf,
tryParse,
type Values,
} from "../common_util/common_util_formula_doc_view";
export type Values = Record<string, unknown>;
export {
evalNode,
leafNum,
lineMap,
offLine,
offZero,
takenOf,
tryParse,
type Values,
} from "../common_util/common_util_formula_doc_view";
const HIDE = new Set(["반올림", "올림", "버림"]);
const SHOW: Record<string, string> = {
"*": "×",
@@ -48,94 +62,6 @@ const PREC: Record<string, number> = {
};
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;
}
}
/** 만약 사슬이 탄 끝 갈래가 수 하나면 그 수(만약이 아니거나 모르면 undefined) */
export function leafNum(n: Node, vals: Values): number | undefined {
let at = n;
let hop = 0;
for (let pick = takenOf(at, vals); pick; pick = takenOf(at, vals), hop++)
at = (at as ["call", string, Node[]])[2][pick];
return hop && at[0] === "num" ? at[1] : undefined;
}
/** 선택으로 꺼진 금액 줄 — 수량 · 단가가 만약으로 0 을 탐(663-1 · 고침 모드에서만 보임) */
export function offLine(line: { 수량?: unknown; 단가?: unknown }, vals: Values): boolean {
return [line.수량, line.단가].some((t) => {
const n = tryParse(t);
return !!n && leafNum(n, vals) === 0;
});
}
/** 만약 — 탄 갈래 차례(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;
@@ -389,7 +315,7 @@ export function reach(doc: FormulaDoc, vals: Values, showOff = false): string[]
from(l.식);
}
for (const l of doc.금액 ?? []) {
if (!showOff && offLine(l, vals)) continue;
if (!showOff && offLine(l, vals, lines)) continue;
from(l.수량);
from(l.단가);
}
+168 -175
View File
@@ -8,6 +8,9 @@
* · 캐시에 없는 연결만 뒤에서 `POST {길}/preview`(옛 요청 끊음) → 캐시에 더해 다시 셈
* 고침 = 캐시(sessionStorage `opt.cache`)에 쌓고 [수식 저장] 한 번에 `POST {길}/save`(서버가 정본으로 다시 셈 · 파일에 씀)
* 무엇을 지워도 오류 없음 — 표지(정의 필요 · 순환 · 0 나눔 …) + 금액 「—」
* M02 사본(`opt.copy` · 802-4b-2) — 길 앞머리 = SK 마다(`/m02/layers/{층}/sangeun/{키}/doc`) · 옵션 = 문서 값(SK 고른 값 · 저장) ·
* [단가 갱신] · [재생성] · 「M01 새 판 있음」(`formula_doc_copy.ts`) · 단가산출 연결 끊기 · 다시 잇기(`formula_doc_sub.ts`) ·
* 저장 몸 {문서, 끊음, 판} · `admin` = 층 쓰기 권한(없으면 읽기만 · 옵션도 잠금)
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
@@ -22,15 +25,11 @@ import {
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { createInputField, createSelectField } from "@ui/ui_template_input";
import { createInputField } from "@ui/ui_template_input";
import {
DocError,
goodId,
rename,
splitId,
tidy,
varId,
type DocVar,
type FormulaDoc,
type Link,
type MoneyLine,
@@ -38,10 +37,24 @@ import {
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 {
askJob,
copyBadges,
copyTools,
type CopyJob,
type CopyMark,
type CopyOptions,
type JobReply,
} from "./formula_doc_copy";
import { buildDefs, type Pick } from "./formula_doc_defs";
import { editLine, editLit, editMoney, ensureVar, splitLine, tidyText } from "./formula_doc_edit";
import { buildLines, type EditAt } from "./formula_doc_lines";
import { evalNode, reach, swapLit, tryParse } from "./formula_doc_render";
import { buildOptions } from "./formula_doc_options";
import { reach } from "./formula_doc_render";
import { subBlock } from "./formula_doc_sub";
import { ft } from "./formula_doc_text";
export { splitLine };
import "./formula_doc.css";
export interface DocScreenOptions {
@@ -50,24 +63,25 @@ export interface DocScreenOptions {
admin: boolean;
/** sessionStorage 열쇠 */
cache: string;
/** M02 사본(SK) — 길 앞머리 · 판 · 끊음 · 할일 */
copy?: CopyOptions;
}
type Docs = Record<string, FormulaDoc>;
interface Loaded {
문서: FormulaDoc;
셈: DocAnswer;
연결: DocRecord;
새?: boolean;
부름?: Docs;
끊음?: Docs;
사본?: CopyMark;
판?: string;
}
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" });
@@ -92,9 +106,15 @@ export function createDocScreen(opt: DocScreenOptions) {
let ctl: AbortController | null = null;
let waiting = false;
const tables = new Map<string, Promise<DocTable | null>>();
const cp = opt.copy;
let cut: Docs = {}; // 사본 — 끊은 단가산출 문서(이 사본 안 · 고칠 수 있음)
let savedCut = "{}";
let calls: Docs = {}; // 사본 — 부름 문서(다시 잇기 = 이 문서로)
let mark: CopyMark | undefined;
let version = "";
const call = async <T>(path: string, body?: unknown, signal?: AbortSignal): Promise<T> => {
const r = await fetch(`${API_BASE_URL}${opt.base}${path}`, {
const r = await fetch(`${API_BASE_URL}${cp ? cp.route(key, path) : opt.base + path}`, {
method: body === undefined ? "GET" : "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
@@ -102,7 +122,12 @@ export function createDocScreen(opt: DocScreenOptions) {
signal,
});
const data = (await r.json().catch(() => ({}))) as T & { detail?: unknown };
if (!r.ok) throw new Error(String(data.detail ?? r.statusText));
if (!r.ok) {
const why = data.detail;
throw Object.assign(new Error(typeof why === "string" ? why : r.statusText), {
status: r.status,
});
}
return data;
};
@@ -114,11 +139,15 @@ export function createDocScreen(opt: DocScreenOptions) {
return {};
}
};
const dirty = (): boolean => !!doc && JSON.stringify(doc) !== saved;
const dirty = (): boolean =>
!!doc && (JSON.stringify(doc) !== saved || JSON.stringify(cut) !== savedCut);
const cutKey = (): string => `${key}#끊음`;
const keep = (): void => {
const all = drafts();
if (dirty()) all[key] = doc!;
else delete all[key];
if (cp && dirty()) all[cutKey()] = cut as unknown as FormulaDoc;
else delete all[cutKey()];
try {
sessionStorage.setItem(opt.cache, JSON.stringify(all));
} catch {
@@ -153,14 +182,16 @@ export function createDocScreen(opt: DocScreenOptions) {
ctl = new AbortController();
const mine = ++seq;
waiting = true;
void call<{ 셈: DocAnswer; 연결: DocRecord }>(
void call<{ 셈: DocAnswer; 연결: DocRecord; 부름?: Docs }>(
"/preview",
{ 문서: doc, 입력: usable() },
cp ? { 문서: doc, 끊음: cut } : { 문서: doc, 입력: usable() },
ctl.signal,
)
.then((got) => {
if (mine !== seq) return;
cache.merge(got.연결);
calls = { ...calls, ...(got.부름 ?? {}) };
cache.merge({ 문서: cut });
waiting = false;
recalc();
draw();
@@ -178,63 +209,11 @@ export function createDocScreen(opt: DocScreenOptions) {
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);
});
// ── 고침(`formula_doc_edit.ts`) ──
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);
}
editMoney(d, i, patch);
if (patch && "이름" in patch && !d.금액?.[i]?.단가) auto = { kind: "price", i };
});
const onPickVar = (id: string, num?: HTMLElement): void => {
@@ -242,7 +221,7 @@ export function createDocScreen(opt: DocScreenOptions) {
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 }));
change((d) => (ensureVar(d, id).연결 = { ...v.연결, 값: Number.isFinite(n) ? n : t }));
});
return;
}
@@ -275,98 +254,60 @@ export function createDocScreen(opt: DocScreenOptions) {
}
};
/** 단가산출 연결 펼침 — 그 문서의 식 · 값 식(읽기만) */
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 changeCut = (k: string, fn: (d: FormulaDoc) => void): void => {
if (!cut[k]) return;
fn(cut[k]);
cache.merge({ 문서: { [k]: cut[k] } });
keep();
recalc();
draw();
};
const onCut = (k: string): void => {
const from = subDoc(k);
if (!from) return;
cut = { ...cut, [k]: clone(from) };
changeCut(k, () => undefined);
};
const onJoin = async (k: string): Promise<void> => {
if (!(await showConfirmDialog(ft("Ask_Relink"), ft("Relink")))) return;
const { [k]: _gone, ...rest } = cut;
cut = rest;
if (calls[k]) cache.merge({ 문서: { [k]: calls[k] } });
else asked = ""; // 부름 문서를 아직 모름 — 뒤에서 서버에 물음
keep();
recalc();
draw();
};
const sub = (id: string): HTMLElement | null =>
subBlock(id, {
doc,
answer,
cache,
...(cp
? {
copy: {
edit: opt.admin,
has: (k: string) => k in calls || k in cut,
cut: (k: string) => cut[k],
onCut,
onJoin: (k: string) => void onJoin(k),
change: changeCut,
},
}
: {}),
});
// ── 그림 ──
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;
};
/** 옵션 가로 — M01 = 화면 값만(`given`) · 사본 = 문서 값(SK 고른 값 · 저장) */
const optionBar = (): HTMLElement[] =>
buildOptions(doc, given, !!cp && !opt.admin, (k, v, x) => {
const at = doc?.변수?.indexOf(v) ?? -1; // 옵션 열쇠 ≠ 기호 — 그 정의 자리에 값
if (cp) return change((d) => at >= 0 && (d.변수![at].연결 = { ...v.연결!, 값: x }));
given[k] = x;
recalc();
draw();
});
const headLine = (): HTMLElement => {
const a = answer;
@@ -414,8 +355,10 @@ export function createDocScreen(opt: DocScreenOptions) {
if (fresh && !dirty())
bits.push(el("span", { className: "ui-text ui-text--muted", text: ft("Fresh") }));
if (dirty()) bits.push(createBadge(ft("Dirty"), "warning"));
if (cp) bits.push(...copyBadges(mark, opt.admin));
const tools = opt.admin
? [
...(cp ? copyTools(true, (job) => void runJob(job)) : []),
createButton({
label: ft("Discard"),
variant: "ghost",
@@ -494,10 +437,10 @@ export function createDocScreen(opt: DocScreenOptions) {
vars,
marks: markMap,
auto,
onLine,
onLine: (i, t) => change((d) => editLine(d, i, t)),
onExpr: (i, t) => change((d) => (d.식![i].식 = tidyText(t))),
onMoney,
onLit,
onLit: (w, i, at, v) => change((d) => editLit(d, w, i, at, v)),
onPickVar,
onAddLine: () => {
auto = { kind: "line", i: (doc!.식 ?? []).length };
@@ -538,9 +481,9 @@ export function createDocScreen(opt: DocScreenOptions) {
setLink: (id, link: Link) =>
change((d) => {
const clean = Object.fromEntries(Object.entries(link).filter(([, x]) => x !== undefined));
ensure(d, id).연결 = clean as Link;
ensureVar(d, id).연결 = clean as Link;
}),
setVar: (id, patch) => change((d) => Object.assign(ensure(d, id), patch)),
setVar: (id, patch) => change((d) => Object.assign(ensureVar(d, id), patch)),
rename: (id, sign, low, up) => {
const copy = clone(doc!);
try {
@@ -572,7 +515,7 @@ export function createDocScreen(opt: DocScreenOptions) {
(subDoc(k)?.변수 ?? [])
.filter((v) => ["선택", "입력", "숫자"].includes(v.연결?.갈래 ?? "") && v.열쇠)
.map((v) => v.열쇠!),
sub: subBlock,
sub,
});
grid.replaceChildren(
el("div", { className: "fdoc__colhead", text: ft("Sym_Head") }),
@@ -600,19 +543,59 @@ export function createDocScreen(opt: DocScreenOptions) {
cache.merge(got.연결);
saved = JSON.stringify(got.문서);
doc = clone(got.문서);
if (!cp) return;
calls = got.부름 ?? {};
cut = clone(got.끊음 ?? {});
savedCut = JSON.stringify(cut);
mark = got.사본;
version = got.판 ?? "";
};
const save = async (): Promise<void> => {
const body = (): Record<string, unknown> =>
cp ? { 문서: doc, 끊음: cut, 판: version } : { 문서: doc };
/** 사본 [단가 갱신] · [재생성] — 미리 셈 → 알림 → 저장(고친 것 먼저 저장 · 버림) */
const runJob = async (job: CopyJob): Promise<void> => {
if (!doc || !cp) return;
if (dirty()) return void showToast(ft("Dirty_First"), "warning");
showLoadingOverlay();
let got: JobReply;
try {
got = await call<JobReply>("/preview", { 할일: job });
} catch (e) {
hideLoadingOverlay();
return void showToast(
`${ft("Save_Failed")} — ${e instanceof Error ? e.message : ""}`,
"error",
);
}
hideLoadingOverlay();
const names = new Map(Object.entries({ ...calls, ...cut }).map(([k, d]) => [k, d.이름 ?? ""]));
if (await askJob(job, got, names)) await save({ 할일: job, 판: version }, ft("Job_Done"));
};
const save = async (send = body(), done = ft("Saved")): Promise<void> => {
if (!doc) return;
showLoadingOverlay();
try {
const got = await call<Loaded>("/save", { 문서: doc });
const got = await call<Loaded>("/save", send).catch(async (e: { status?: number }) => {
// 사본 판 바뀜(페이지 [저장] 등) — 수식 사본이 그대로면 새 판으로 한 번 더
if (!cp || e.status !== 409) throw e;
const now = await call<Loaded>("");
if (
JSON.stringify([now.문서, now.끊음 ?? {}]) !==
JSON.stringify([JSON.parse(saved), JSON.parse(savedCut)])
)
throw e;
return call<Loaded>("/save", { ...send, 판: (version = now.판 ?? "") });
});
take(got);
fresh = false;
keep();
recalc();
draw();
showToast(ft("Saved"), "success");
if (cp && got.판) cp.onSaved?.(got.판);
showToast(done, "success");
} catch (e) {
showToast(`${ft("Save_Failed")} — ${e instanceof Error ? e.message : ""}`, "error");
} finally {
@@ -620,9 +603,15 @@ export function createDocScreen(opt: DocScreenOptions) {
}
};
const back = (): void => {
doc = JSON.parse(saved) as FormulaDoc;
cut = JSON.parse(savedCut) as Docs;
cache.merge({ 문서: { ...calls, ...cut } });
};
const discard = async (): Promise<void> => {
if (!doc || !dirty()) return;
doc = JSON.parse(saved) as FormulaDoc;
back();
openDef = null;
keep();
recalc();
@@ -640,19 +629,23 @@ export function createDocScreen(opt: DocScreenOptions) {
asked = "";
cache = createDocCache();
tables.clear();
[cut, calls, mark, savedCut, version] = [{}, {}, undefined, "{}", ""];
draw();
if (!next) {
head.replaceChildren(el("p", { className: "ui-text ui-text--muted", text: ft("Need_Save") }));
const why = cp ? ft("No_Key") : ft("Need_Save");
head.replaceChildren(el("p", { className: "ui-text ui-text--muted", text: why }));
return;
}
const mine = ++seq;
try {
const got = await call<Loaded>(`?key=${encodeURIComponent(next)}`);
const got = await call<Loaded>(cp ? "" : `?key=${encodeURIComponent(next)}`);
if (mine !== seq || key !== next) return;
take(got);
fresh = !!got.새;
const draft = drafts()[next];
if (draft) doc = clone(draft);
const kept = cp ? (drafts()[`${next}#끊음`] as unknown as Docs | undefined) : undefined;
if (kept) cache.merge({ 문서: (cut = clone(kept)) });
recalc();
draw();
} catch (e) {
@@ -669,7 +662,7 @@ export function createDocScreen(opt: DocScreenOptions) {
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;
back();
keep();
return true;
};
+147
View File
@@ -0,0 +1,147 @@
/* =============================================================================
* formula_doc_sub.ts
* 단가산출 연결 펼침(PLAN 802-3 · 802-4b-2 · 요구 659-3 · 796-2e 뜻) — 정의 영역에서 단가산출 기호를 열면 그 문서의 식 · 값 식
* M01 = 읽기만 · M02 사본(`copy`) = 머리 「연결」 + [연결 끊기](이 사본 안 자기 문서로 굳힘 · 금액 그대로) ·
* 「끊음」 + [다시 잇기](고친 것 버림 · 부름 문서로) · 끊은 문서 = 그 자리에서 식 줄 · 금액 줄 · 숫자 고침(`formula_doc_edit.ts`)
* 셈 = 같은 계산기 · 연결 캐시(끊은 문서는 캐시 문서를 덮어씀 — 부른 쪽이 `merge`)
* ========================================================================== */
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 } from "@ui/ui_template_elements";
import { varId, type FormulaDoc, type Link } from "../common_util/common_util_formula_doc";
import { calcDoc, type DocAnswer } from "../common_util/common_util_formula_doc_calc";
import type { DocCache } from "../common_util/common_util_formula_doc_cache";
import { editLine, editLit, editMoney, ensureVar, tidyText } from "./formula_doc_edit";
import { buildLines, type EditAt } from "./formula_doc_lines";
import { evalNode, tryParse } from "./formula_doc_render";
import { ft } from "./formula_doc_text";
export interface SubCopy {
/** 층 쓰기 권한 */
edit: boolean;
/** 이 사본이 부르는 키(부름 · 끊음) */
has: (key: string) => boolean;
/** 끊은 문서(없으면 연결) */
cut: (key: string) => FormulaDoc | undefined;
onCut: (key: string) => void;
onJoin: (key: string) => void;
/** 끊은 문서 고침 — 캐시 · 셈 · 그림까지 부른 쪽이 */
change: (key: string, fn: (d: FormulaDoc) => void) => void;
}
export interface SubHooks {
doc: FormulaDoc | null;
answer: DocAnswer | null;
cache: DocCache;
copy?: SubCopy;
}
let next: { key: string; at: EditAt } | null = null; // 끊은 문서에 새 줄 — 다음 그림에서 바로 고침
const subDoc = (cache: DocCache, k: string): FormulaDoc | null => {
try {
return cache.links.doc(k) ?? null;
} catch {
cache.takeMissing();
return null;
}
};
function linkHead(k: string, c: SubCopy): HTMLElement | null {
if (!c.has(k)) return null;
const cut = !!c.cut(k);
const tools = c.edit
? [
createButton({
label: cut ? ft("Relink") : ft("Unlink"),
variant: "ghost",
size: 24,
onClick: () => (cut ? c.onJoin(k) : c.onCut(k)),
}),
]
: [];
const head = el("div", {
className: "fdoc__sublink",
attrs: { "data-sub-link": cut ? "cut" : "linked" },
children: [createBadge(cut ? ft("Cut") : ft("Linked"), cut ? "warning" : "info"), ...tools],
});
return head;
}
/** 단가산출 연결 펼침 — 그 문서의 식 · 값 식(끊은 사본 문서면 고침) */
export function subBlock(id: string, h: SubHooks): HTMLElement | null {
const link: Link | undefined = h.doc?.변수?.find((v) => varId(v) === id)?.연결;
if (link?.갈래 !== "단가산출" || !link.키 || link.키.includes("{")) return null;
const k = link.키;
const sub = subDoc(h.cache, k);
if (!sub) return null;
const inputs: Record<string, unknown> = {};
for (const [name, t] of Object.entries(link.입력 ?? {})) {
const n = tryParse(t);
const v = n ? evalNode(n, h.answer?.값 ?? {}) : undefined;
if (v !== undefined) inputs[name] = v;
}
const got = calcDoc(sub, h.cache.links, inputs);
h.cache.takeMissing();
const c = h.copy;
const live = !!(c?.edit && c.cut(k));
const put = (fn: (d: FormulaDoc) => void): void => c!.change(k, fn);
const none = (): void => undefined;
const auto = next?.key === k ? next.at : null;
next = null;
const box = el("div", { className: "fdoc__grid fdoc__sub" });
box.append(
...buildLines({
admin: live,
doc: sub,
answer: got,
vars: new Map((sub.변수 ?? []).map((v) => [varId(v), v])),
marks: new Map(),
auto,
onLine: live ? (i, t) => put((d) => editLine(d, i, t)) : none,
onExpr: live ? (i, t) => put((d) => (d.식![i].식 = tidyText(t))) : none,
onMoney: live ? (i, p) => put((d) => editMoney(d, i, p)) : none,
onLit: live ? (w, i, at, v) => put((d) => editLit(d, w, i, at, v)) : none,
onPickVar: (vid, num) => {
const v = sub.변수?.find((x) => varId(x) === vid);
if (!live || !num || v?.연결?.갈래 !== "숫자") return;
editNumber(num, (t) => {
const n = Number(t.replace(/,/g, ""));
put((d) => (ensureVar(d, vid).연결 = { ...v.연결, 값: Number.isFinite(n) ? n : t }));
});
},
onAddLine: () => {
next = { key: k, at: { kind: "line", i: (sub.식 ?? []).length } };
put((d) => (d.식 ??= []).push({ 기호: "", 식: "" }));
},
onAddMoney: (g) => {
next = { key: k, at: { kind: "name", i: (sub.금액 ?? []).length } };
put((d) =>
(d.금액 ??= []).push({ 이름: "", 비목: "노무비", 단가: "", ...(g ? { 묶음: g } : {}) }),
);
},
onGroupName: none,
onRedraw: () =>
put((d) => {
d.식 = (d.식 ?? []).filter((l) => String(l.기호 ?? "") || String(l.식 ?? ""));
d.금액 = (d.금액 ?? []).filter((l) => l.이름 || l.단가 || l.수량);
}),
}),
);
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.결과단위 ?? ""}` : "—",
}),
);
}
const head = c ? linkHead(k, c) : null;
if (!head) return box;
return el("div", { attrs: { "data-sub": k }, children: [head, box] });
}
+27
View File
@@ -69,6 +69,33 @@ const TEXT = {
Bad_Sign: ["기호 꼴 아님 · 같은 기호 있음", "Bad or duplicate symbol"],
More: ["더 있음 — 찾는 말을 더 넣으세요", "More — refine search"],
Nothing: ["찾은 것 없음", "Nothing found"],
Linked: ["연결", "Linked"],
Cut: ["끊음", "Unlinked"],
Unlink: ["연결 끊기", "Unlink"],
Relink: ["다시 잇기", "Relink"],
Ask_Relink: [
"이 기초에서 고친 것을 버리고 부르던 문서로 돌아감",
"Discard edits to this basis and follow the copy again",
],
New_M01: ["M01 새 판 있음", "New M01 version"],
Price: ["단가 갱신", "Refresh prices"],
Regen: ["재생성", "Regenerate"],
Price_Ask: ["자원 단가만 지금 M01 값으로 — 식 · 연결 그대로", "Prices only — equation unchanged"],
Regen_Ask: ["지금 M01 수식 문서로 다시 찍음", "Re-copy from the current M01 equation"],
Regen_Lost: ["버리는 것", "Discarded"],
Regen_Edit: ["수식 고침", "Equation edits"],
Changed: ["바뀐 자원", "Changed resources"],
Before_After: ["계", "Sum"],
Job_Done: ["적용함", "Applied"],
Dirty_First: [
"저장 안 한 고침이 있음 — [수식 저장] 또는 [고침 버림] 먼저",
"Save or discard edits first",
],
No_Key: [
"저장한 단가산출만 수식 문서를 엶 — [단가산출 상세] 탭",
"Saved items only — use the detail tab",
],
Read_Only: ["읽기만", "Read only"],
} as const;
export type TextKey = keyof typeof TEXT;
+69
View File
@@ -0,0 +1,69 @@
"""M02 단가산출(SK) 수식 탭 화면 몫(PLAN 802-4b-2 · 요구 660~665) — M01 수식 문서 화면과 같은 꼴.
- 연결 기록 `record(사본)` — 사본 셈이 쓴 요소 값 · 표 · 단가산출 문서(끊음 먼저)를 화면 캐시로
(화면 = 같은 계산기로 즉시 셈 · `M02_Doc_Sk_Path.answer` 가 `연결` 칸으로 붙임)
- 연결 후보 · 표 하나 = M01 수식 문서 화면 것(`M01_MasterData_Doc`) 그대로 — 로그인 · 층 읽기 권한
POST /api/m02/layers/{층}/sangeun/{키}/doc/links {갈래, 검색, 분류} → {후보, 더있음}
GET /api/m02/layers/{층}/sangeun/{키}/doc/table?key=<표 키> → 표(조건 · 칸)(M01 길 꼴)
⚠ 등록은 `main.py`(로그인만 · 층 권한은 길 안에서).
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from common_util.common_util_auth import verify_session
from M02_MasterTemplete import M02_Doc_Copy as dc
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root
router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Sangeun Doc Screen"])
def record(copy: dict) -> dict[str, Any]:
"""사본 문서 셈이 쓴 연결 기록 — 값 사본 색인 위 · 부름(끊음 먼저) 문서."""
import master_doc_record as mr # `M02_Doc_Copy` 가 scripts 를 길에 둔 뒤
rec = mr.Rec(dc.ml.Links(dc.over(copy), dc.docs_of(copy)))
try:
rec.prefetch(copy["문서"])
dc.mc.calc(copy["문서"], rec)
except dc._ERR: # 셈 틀림 = 셈 답 표지(`dc.run`) — 기록은 모은 데까지
pass
return rec.out()
class LinksBody(BaseModel):
갈래: str
검색: str = ""
분류: str = "인력"
def _m01_doc():
from M01_MasterData import M01_MasterData_Doc as m01_doc
return m01_doc
@router.post("/layers/{layer}/sangeun/{sk}/doc/links")
def doc_links(
layer: Layer,
sk: str,
body: LinksBody,
owner: str | None = None,
session: Session = Depends(verify_session),
) -> dict:
return _call(_root(session, layer, owner), _m01_doc().candidates, body.model_dump())
@router.get("/layers/{layer}/sangeun/{sk}/doc/table")
def doc_table(
layer: Layer,
sk: str,
key: str,
owner: str | None = None,
session: Session = Depends(verify_session),
) -> dict:
return _call(_root(session, layer, owner), _m01_doc().table, key)
+3 -1
View File
@@ -16,6 +16,7 @@ import json
from typing import Any
from M02_MasterTemplete import M02_Doc_Copy as dc
from M02_MasterTemplete import M02_Doc_Screen as screen
from M02_MasterTemplete import M02_Doc_Sk as dsk
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Sangeun_Copy as skc
@@ -72,7 +73,7 @@ def current(doc: dict, job: Any = None) -> dict:
def answer(copy: dict) -> dict[str, Any]:
"""{문서, 부름, 끊음, 셈, 사본 표지} — 글 · 수는 JSON 꼴."""
"""{문서, 부름, 끊음, 셈, 사본 표지, 연결 기록} — 글 · 수는 JSON 꼴."""
got = dc.run(copy["문서"], copy)
head = {k: copy.get(k) for k in ("시점", "단가시점")}
mark = {**head, "따로": bool(copy.get("따로")), **dc.state(copy)}
@@ -82,6 +83,7 @@ def answer(copy: dict) -> dict[str, Any]:
"끊음": copy.get("끊음") or {},
"셈": got,
"사본": mark,
"연결": screen.record(copy), # 화면 캐시(802-4b-2)
}
return json.loads(json.dumps(dc.ml._norm(body), ensure_ascii=False, default=str))
@@ -1,7 +1,7 @@
/* =============================================================================
* M02_MasterTemplete_Sangeun_Detail.ts
* M02 단가산출(SK) 상세(PLAN 717-2b · 724-1 · 요구서 4장 3 · 4 · 13 · 17 · 24) — M01 「단가산출 로직」 상세 부품을 M02 길로.
* 보기 둘(`_Sangeun_Tabs` · 799-2) = 수식(796-2b) · 단가산출 상세(공용 상세 `LogicLab_Ho` · SK 셈 답 줄 = `hoView` · 줄 모달 그 자리 고침 =
* 보기 둘(`_Sangeun_Tabs` · 799-2) = 수식(공용 수식 문서 화면 · 802-4b-2 · 저장은 그 탭 [수식 저장] → 판 · 수식 칸 새로) · 단가산출 상세(공용 상세 `LogicLab_Ho` · SK 셈 답 줄 = `hoView` · 줄 모달 그 자리 고침 =
* HoEdit → SK 덮개 · 더한줄 · 줄 빼기 · 별도계상) · 기본정보 = 바탕 로직(맨 위 접힘) — 시트 · 인력·재료·경비 · 시험 계산 · 텍스트 수식은 걷음
* 고침 = SK 덮개 · 더한줄(`Sangeun_Pick`) · 뺀줄(꼴 = 서버 `M02_Sangeun_Doc`) · 값 = 서버 셈 길 최신 응답만
* 옵션 칸 = SK `입력값`(727-3 · 저장 · 열린입력만 시험 값) · [저장] = 화면 문서 → 서버가 다시 셈(원값 · 바탕판)
@@ -30,7 +30,7 @@ import {
} from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
import { buildLabDetail } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Detail";
import { buildHo, lineOpener, type HoEdit } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Ho";
import type { Layer } from "./M02_MasterTemplete_Api_Fetch";
import { readTemplate, type Layer } from "./M02_MasterTemplete_Api_Fetch";
import { baseHeads } from "./M02_MasterTemplete_Sangeun_BaseHead";
import { calcSangeun, removedStrip } from "./M02_MasterTemplete_Sangeun_Cover";
import { openSangeunLinePicker } from "./M02_MasterTemplete_Sangeun_Pick";
@@ -63,6 +63,8 @@ export interface SangeunDetailOptions {
onChanged: (dirty: boolean) => void;
/** 752-1 오른쪽 원문 패널 — 단가산출 화면만 켬(일위대가 줄 모달 안 상세는 끔) */
source?: boolean;
/** 수식 탭 저장 뒤 그 SK 새 판(페이지 [저장] 409 막음 · 802-4b-2) */
onVersion?: (version: string) => void;
}
const LIB = { read: "/m02/logic-lib", calc: "/m02/logic-lib" };
@@ -98,12 +100,22 @@ export function mountSangeunDetail(
const head = el("div", { className: "m02-sk__head", attrs: { "data-sk-head": "" } });
const notes = el("div");
const editor = el("div", { className: "m01-logic__editor" });
const tabs = createSangeunTabs(editor, {
edit,
scope,
touch: () => touch(),
onInput: (n, v) => onInput(n, v),
});
const tabs = createSangeunTabs(editor, { edit, scope, onSaved: (v) => void formulaSaved(v) });
/** 수식 탭 저장 = 파일 판 · `수식` 칸이 바뀜 — 페이지 판 · 이 화면 문서 `수식` 을 새로(상세 탭 셈이 씀) */
async function formulaSaved(version: string): Promise<void> {
opt.onVersion?.(version);
try {
const got = (await readTemplate(opt.layer, "sangeun", doc.키 ?? "", null, opt.owner))
.문서 as SkDoc;
if (!alive) return;
const was = JSON.parse(saved) as SkDoc;
doc.수식 = was.수식 = got.수식;
saved = JSON.stringify(was);
touch();
} catch {
/* 못 읽음 = 다음 열기 때 새로 */
}
}
const ownHost = el("div");
const addButton = createButton({
label: L("M02_SangeunAddLine"),
@@ -333,7 +345,7 @@ export function mountSangeunDetail(
];
const row = base ? { ...base.logic, 입력: [...(base.logic.입력 ?? []), ...extra] } : standIn();
const view = ans ? hoView(ans, bare, base?.logic ?? null, doc) : null;
tabs.draw({ doc, ans, base: row, values });
tabs.draw(/^SK-\d{6}$/.test(doc.키 ?? "") ? (doc.키 ?? "") : "");
const hoCtx = {
row: view ? { ...row, 호표: view.호표, 덧줄: view.덧줄 } : row,
prices: base?.prices ?? {},
@@ -501,7 +513,6 @@ export function mountSangeunDetail(
if (one) one.바탕판 = l.바탕판; // 둘째 기초도 같음(727-2)
}
saved = JSON.stringify(doc);
tabs.saved();
return clone(doc);
},
getValues: () => ({ ...values }), // 723-3 — 일위대가 줄 모달 [적용] = 옵션을 그 줄에 남김
@@ -1,286 +0,0 @@
/* =============================================================================
* M02_MasterTemplete_Sangeun_Equation.ts
* M02 단가산출(SK) 「수식」 보기(PLAN 796-2b · 요구 655 · 656 · 657 · 요구서_655 0 장) — M01 수식 탭 부품(`createLabEquation`)을 페이지 갈래로.
* 길 = `POST /layers/{층}/sangeun/equation`(+ `/preview`) · 몸 = 화면 문서(캐시) · 고침 목록 = 문서 `수식고침`(사본 위 기록) ·
* 저장 = 상세 [저장](서버가 다시 얹어 검사 · 셈) · 옵션 = 상세 옵션 값(`onInput`)
* 위 줄 = 로직 사본 — 「M01 새 판 있음」 배지 · [단가 갱신] · [재생성](`…/sangeun/copy/price|regen` → 문서 `사본할일` · [저장] 때 서버 적용)
* 가져온 식 = 「엑셀 원래」 금액 글(셈 답 `원래` = 엑셀 금액)
* 더한 기초 블록(796-2e · 요구 659-3) — 머리 「연결」 + [연결 끊기](`…/sangeun/block/unlink` → 그 더한줄 `끊음` = 이 SK 안 자기 사본) ·
* 「끊음」 + [다시 잇기](`…/block/relink` → 고친 것 버림 · 원본 따라감) · 끊은 블록 속 = 같은 수식 부품(길 몸 `블록`) · 고침 = 그 더한줄 `수식고침`
* ========================================================================== */
import { createBadge } from "@ui/ui_template_badge";
import { createButton } from "@ui/ui_template_button";
import { el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
import type { EqSumHooks, EqSumRow } from "@ui/ui_template_equation";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createText } from "@ui/ui_template_text";
import { createToolbar } from "@ui/ui_template_toolbar";
import { formatMoney } from "../common_util/common_util_money";
import type { LogicRow } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
import { createLabEquation } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Equation";
import type { EqEdit } from "../M01_MasterData/logic/M01_Logic_Equation_Model";
import { call, type Layer } from "./M02_MasterTemplete_Api_Fetch";
import type { SkAnswer, SkDoc } from "./logic/M02_Sangeun_Cover_Model";
const TEXT = {
Copy: ["로직 사본", "Logic copy"],
NewM01: ["M01 새 판 있음", "New M01 version"],
Price: ["단가 갱신", "Refresh prices"],
Regen: ["재생성", "Regenerate"],
Job: ["{n} · [저장] 때 적용", "{n} · applied on save"],
Excel: ["엑셀 원래", "Excel original"],
Lost: ["못 얹은 고침", "Not re-applied"],
Linked: ["연결", "Linked"],
Cut: ["끊음", "Unlinked"],
Unlink: ["연결 끊기", "Unlink"],
Relink: ["다시 잇기", "Relink"],
AskUnlink: [
"이 기초를 이 단가산출 안 사본으로 굳힘 — 끊은 뒤 원본(M01) 바뀜은 따라오지 않음 (금액은 그대로)",
"Freeze this basis as a copy inside this item — later source changes will not follow (amount unchanged)",
],
AskRelink: [
"이 기초에서 고친 것을 버리고 원본(M01)을 따라감으로 돌아감",
"Discard edits to this basis and follow the source again",
],
KeptCut: ["끊은 기초 {n} 는 줄 그대로 · 자원 단가만", "Unlinked {n}: prices only"],
} as const satisfies Record<string, readonly [string, string]>;
const t = (k: keyof typeof TEXT, n = ""): string =>
(TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0]).replace("{n}", n);
const KEY = "수식고침";
const JOB = "사본할일";
interface CopyMark {
M01새판?: boolean;
지운키?: string[];
}
type Answer = SkAnswer & {
사본?: CopyMark;
원래?: Record<string, number> | null;
가져온?: unknown;
};
interface CopyReply {
문서: SkDoc;
전: Record<string, number> | null;
후: Record<string, number> | null;
바뀐자원?: unknown[];
못얹은?: { cid: string; 까닭: string }[];
끊은기초?: string[];
}
export interface SkEquationOptions {
scope: { layer: Layer; owner: string | null };
edit: boolean;
touch: () => void;
onInput: (name: string, value: string) => void;
}
export interface SkEquationDraw {
doc: SkDoc;
ans: SkAnswer | null;
base: LogicRow | null;
values: Record<string, string>;
}
export function createSangeunEquation(opt: SkEquationOptions) {
const { layer, owner } = opt.scope;
const q = owner && layer !== "system" ? `?owner=${encodeURIComponent(owner)}` : "";
const bar = el("div", { attrs: { "data-sk-copy": "" } });
let last: SkEquationDraw | null = null;
const doc = (): SkDoc => last?.doc ?? ({} as SkDoc);
const added = (k: number): Record<string, unknown> | undefined =>
doc().더한줄?.[k] as Record<string, unknown> | undefined;
const post = <T>(path: string, body: unknown, signal?: AbortSignal) =>
call<T>(`/layers/${layer}/sangeun/${path}${q}`, {
method: "POST",
body: JSON.stringify(body),
signal,
});
/** 고침 = 저장되는 SK 만(일위대가 줄 모달 `줄모드` = 보기만 — 줄로 못 옮김) · 처음 그릴 때 정함 */
const make = (d: SkEquationDraw) =>
createLabEquation({
admin: opt.edit && d.doc.줄모드 !== true,
blocked: () => false,
onSaved: () => undefined,
page: {
sum: blockHooks,
ask: (suffix, body, signal) =>
call(`/layers/${layer}/sangeun/equation${suffix}${q}`, {
method: "POST",
body: JSON.stringify({ ...body, 문서: doc() }),
signal,
}),
edits: () => (doc()[KEY] as EqEdit[] | undefined) ?? [],
keep: (edits) => {
if (edits.length) doc()[KEY] = edits;
else delete doc()[KEY];
opt.touch();
},
values: () => last?.values ?? {},
setValue: (name, value) => opt.onInput(name, value ?? ""),
},
});
let lab: ReturnType<typeof make> | null = null;
const NO_ROW = { 입력: [] } as unknown as LogicRow; // 끊은 기초 옵션 칸 = 안 그림
const root = el("div", { attrs: { "data-sk-equation": "" }, children: [bar] });
async function job(how: "price" | "regen"): Promise<void> {
try {
const got = await call<CopyReply>(`/layers/${layer}/sangeun/copy/${how}${q}`, {
method: "POST",
body: JSON.stringify({ 문서: doc(), 입력값: last?.values ?? {} }),
});
doc()[JOB] = got.문서[JOB];
const sum = (v: Record<string, number> | null): string => formatMoney(v?.계 ?? 0);
const lost = got.못얹은 ?? [];
const kept = got.끊은기초 ?? [];
showToast(
`${t(how === "price" ? "Price" : "Regen")} ${sum(got.전)} → ${sum(got.후)}` +
(lost.length ? ` · ${t("Lost")} ${lost.map((x) => x.cid).join(" · ")}` : "") +
(kept.length ? ` · ${t("KeptCut", kept.join(" · "))}` : ""),
lost.length ? "warning" : "success",
);
opt.touch();
} catch (error) {
showToast(error instanceof Error ? error.message : String(error), "error");
}
}
/** 더한 기초 연결 끊기 · 다시 잇기(796-2e) — 서버가 고친 그 더한줄만 화면 문서에 끼움 */
async function link(k: number, how: "unlink" | "relink"): Promise<void> {
const ask = t(how === "unlink" ? "AskUnlink" : "AskRelink");
if (!(await showConfirmDialog(ask, t(how === "unlink" ? "Unlink" : "Relink")))) return;
try {
const got = await post<CopyReply>(`block/${how}`, { 문서: doc(), 차례: k });
const one = got.문서.더한줄?.[k];
const list = doc().더한줄;
if (!one || !list) return;
list[k] = one;
blocks.delete(k);
showToast(
`${t(how === "unlink" ? "Unlink" : "Relink")} ${formatMoney(got.전?.계 ?? 0)} → ${formatMoney(got.후?.계 ?? 0)}`,
"success",
);
opt.touch();
} catch (error) {
showToast(error instanceof Error ? error.message : String(error), "error");
}
}
/** 끊은 기초 속 수식 — 기초마다 한 부품(다시 그려도 고르던 줄 · 쓰던 글 그대로) */
const blocks = new Map<number, ReturnType<typeof make>>();
function blockLab(k: number): ReturnType<typeof make> {
let got = blocks.get(k);
if (got) return got;
got = createLabEquation({
admin: true,
blocked: () => false,
onSaved: () => undefined,
page: {
ask: (suffix, body, signal) =>
post(`equation${suffix}`, { ...body, 문서: doc(), 블록: k }, signal),
edits: () => (added(k)?.[KEY] as EqEdit[] | undefined) ?? [],
keep: (edits) => {
const one = added(k);
if (!one) return;
if (edits.length) one[KEY] = edits;
else delete one[KEY];
opt.touch();
},
values: () => (added(k)?.입력값 as Record<string, string> | undefined) ?? {},
setValue: () => undefined,
},
});
blocks.set(k, got);
return got;
}
const blockHooks: EqSumHooks = {
head: (r: EqSumRow): HTMLElement[] => {
const k = r.블록;
if (k === undefined || !r.나무) return [];
const out: HTMLElement[] = [
createBadge(t(r.끊음 ? "Cut" : "Linked"), r.끊음 ? "warning" : "neutral", { size: 18 }),
];
if (opt.edit && doc().줄모드 !== true)
out.push(
createButton({
label: t(r.끊음 ? "Relink" : "Unlink"),
variant: "ghost",
size: 22,
onClick: () => void link(k, r.끊음 ? "relink" : "unlink"),
}),
);
return out;
},
body: (r: EqSumRow): HTMLElement | null => {
const k = r.블록;
const one = k === undefined ? undefined : added(k);
if (k === undefined || !r.끊음 || !one || !opt.edit || doc().줄모드 === true) return null;
const version = JSON.stringify([
one.끊음 && (one.끊음 as { 시점?: string }).시점,
one.입력값,
one.수량,
doc().입력값,
last?.values,
]);
const part = blockLab(k);
part.draw(`${String(doc().키 ?? "")}#${k}`, version, NO_ROW);
return part.root;
},
};
function drawBar(d: SkEquationDraw): void {
const ans = d.ans as Answer | null;
const mark = ans?.사본;
const pending = d.doc[JOB] as string | undefined;
const items: (HTMLElement | "divider" | "spacer")[] = [];
if (ans && "가져온" in ans)
items.push(createText(`${t("Excel")} ${formatMoney(ans.원래?.계 ?? 0)}`, "muted", 14));
if (mark) {
items.push(createText(t("Copy"), "muted", 14));
if (mark.M01새판) items.push(createBadge(t("NewM01"), "warning"));
if (pending) items.push(createBadge(t("Job", pending), "info"));
if (opt.edit) {
items.push(
"spacer",
createButton({
label: t("Price"),
variant: "ghost",
size: 24,
onClick: () => void job("price"),
}),
createButton({
label: t("Regen"),
variant: "ghost",
size: 24,
onClick: () => void job("regen"),
}),
);
}
}
bar.replaceChildren(
...(items.length ? [createToolbar({ items, size: 28, label: t("Copy") })] : []),
);
}
return {
root,
draw: (d: SkEquationDraw): void => {
last = d;
drawBar(d);
if (!d.base) return;
if (!lab) root.append((lab = make(d)).root);
const { [KEY]: _edits, ...rest } = d.doc;
lab.draw(String(d.doc.키 ?? ""), JSON.stringify([rest, d.values]), d.base);
},
/** [저장] 뒤 — 할일 칸은 서버가 적용 · 뗌(상세 `getDoc` 이 복사한 뒤) */
saved: (): void =>
queueMicrotask(() => {
if (!last?.doc[JOB]) return;
delete last.doc[JOB];
drawBar(last);
}),
};
}
@@ -1,20 +1,36 @@
/* =============================================================================
* M02_MasterTemplete_Sangeun_Tabs.ts
* M02 단가산출(SK) 상세 보기 고르기(PLAN 799-2 · 요구 655-8) — 수식(첫 탭 · 796-2b `_Sangeun_Equation`) · 단가산출 상세 둘.
* 시트 · 인력·재료·경비 · 시험 계산 · 텍스트 수식 보기는 걷음(옛 `_Sangeun_Sheet*` · 790-1) — 서버 문서 꼴(변수줄 · 글줄 · 덮개)은 그대로
* 수식 = 이 파일이 쥔 그릇 · 단가산출 상세 = 상세가 쥔 `editor`(수식일 때 숨김)
* M02 단가산출(SK) 상세 보기 고르기(PLAN 799-2 · 802-4b-2 · 요구 655-8 · 660~665) — 수식(첫 탭) · 단가산출 상세 둘.
* 수식 = 공용 수식 문서 화면(`A00_Common/formula_doc_screen.ts` · M01 과 같은 꼴) — 길 앞머리만 M02 사본 길
* (`/m02/layers/{층}/sangeun/{키}/doc` · 802-4a) · 층 쓰기 권한 없으면 읽기만 · 저장 = 그 탭 [수식 저장](페이지 [저장] 아님)
* 옛 M02 수식 나무 화면(796-2b · 2c · 2e · 800 · 801)은 걷음 · 단가산출 상세 = 상세가 쥔 `editor`(수식일 때 숨김)
* 저장 안 한 SK(일위대가 줄 모달 · 새 SK) = 「저장한 단가산출만」 글
* ========================================================================== */
import { createSegment } from "@ui/ui_template_segment";
import { createDocScreen } from "../A00_Common/formula_doc_screen";
import { tl } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Text";
import {
createSangeunEquation,
type SkEquationDraw,
type SkEquationOptions,
} from "./M02_MasterTemplete_Sangeun_Equation";
import type { Layer } from "./M02_MasterTemplete_Api_Fetch";
type View = "equation" | "ho";
export interface SkEquationOptions {
scope: { layer: Layer; owner: string | null };
edit: boolean;
/** [수식 저장] · [단가 갱신] · [재생성] 뒤 그 SK 새 판 */
onSaved: (version: string) => void;
}
/** SK 수식 길 — 꼬리 물음(`/table?key=…`)에 주인 물음을 이어 붙임 */
export function skDocPath(scope: SkEquationOptions["scope"], key: string, path: string): string {
const [tail, query] = path.split("?");
const ask = [query, scope.owner ? `owner=${encodeURIComponent(scope.owner)}` : ""]
.filter(Boolean)
.join("&");
const at = `/m02/layers/${scope.layer}/sangeun/${encodeURIComponent(key)}/doc${tail}`;
return ask ? `${at}?${ask}` : at;
}
/** 보기 고르기 · 수식 그릇 — `editor` = 단가산출 상세 그릇(수식일 때 숨김) */
export function createSangeunTabs(
editor: HTMLElement,
@@ -22,12 +38,18 @@ export function createSangeunTabs(
): {
picker: HTMLElement;
host: HTMLElement;
draw: (d: SkEquationDraw) => void;
saved: () => void;
/** 저장한 SK 키(빈 키 = 저장 전) */
draw: (key: string) => void;
canLeave: () => Promise<boolean>;
} {
const eqView = createSangeunEquation(opt);
const screen = createDocScreen({
base: "",
admin: opt.edit,
cache: "m02_sk_doc_drafts",
copy: { route: (k, p) => skDocPath(opt.scope, k, p), onSaved: opt.onSaved },
});
const show = (v: View): void => {
eqView.root.hidden = v !== "equation";
screen.root.hidden = v !== "equation";
editor.hidden = v === "equation";
if (!editor.hidden) editor.dataset.view = v;
};
@@ -43,10 +65,5 @@ export function createSangeunTabs(
});
picker.root.dataset.skViews = "";
show("equation");
return {
picker: picker.root,
host: eqView.root,
draw: eqView.draw,
saved: eqView.saved, // 796-2b 사본 할일 뗌
};
return { picker: picker.root, host: screen.root, draw: screen.draw, canLeave: screen.canLeave };
}
@@ -11,6 +11,8 @@ import type { Selection } from "./M02_MasterTemplete_UI_Main";
export type SangeunViewOptions = Pick<Selection, "layer" | "owner" | "name"> & {
readOnly: boolean;
onChanged: (dirty: boolean) => void;
/** 수식 탭 저장 뒤 그 SK 새 판(802-4b-2) */
onVersion?: (version: string) => void;
};
export function mountSangeunView(
@@ -374,7 +374,9 @@ export function createMain(
if (at.kind === "sangeun") {
const { mountSangeunView } = await import("./M02_MasterTemplete_Sangeun_View"); // 717-2a
const onChanged = (d: boolean): boolean => (dirty = d && !readOnly);
if (mine === seq) editor = mountSangeunView(host, doc, { ...at, readOnly, onChanged });
const onVersion = (v: string): string => (version = v); // 수식 탭 저장(802-4b-2)
if (mine === seq)
editor = mountSangeunView(host, doc, { ...at, readOnly, onChanged, onVersion });
return;
}
if (at.kind === "table") {
+139
View File
@@ -0,0 +1,139 @@
/* =============================================================================
* common_util_formula_doc_view.ts
* 수식 문서 그림용 값(PLAN 802-3 · 802-4b-2 · 요구 663-1) — 화면 · DOM · fetch 없음(거울 시험이 Node 로 돌림)
* tryParse · evalNode(탄 갈래 값) · takenOf · leafNum · offZero / offLine(선택으로 꺼진 줄)
* 셈 정본은 계산기(`common_util_formula_doc_calc.ts`) — 여기는 그림 · 숨김 판정만
* ========================================================================== */
import { DocError, parse, type Node } from "./common_util_formula_doc";
import { binary, cut, isNum } from "./common_util_formula_doc_calc";
export type Values = Record<string, unknown>;
/** 식 글 → 나무(못 풀면 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;
}
}
/** 만약 사슬이 탄 끝 갈래가 수 하나면 그 수(만약이 아니거나 모르면 undefined) */
export function leafNum(n: Node, vals: Values): number | undefined {
let at = n;
let hop = 0;
for (let pick = takenOf(at, vals); pick; pick = takenOf(at, vals), hop++)
at = (at as ["call", string, Node[]])[2][pick];
return hop && at[0] === "num" ? at[1] : undefined;
}
/** 선택으로 꺼진 0 — 만약이 탄 끝 갈래가 0 · 곱 인수(× · ÷ 위) 하나가 꺼진 0 · 식 줄 기호면 그 줄 식으로 거침
* (663-1 · 802-4b-2) — 입력 · 숫자 0(구멍땜 0 개소) · 글로 적은 0 은 꺼진 것 아님 */
export function offZero(
n: Node | null,
vals: Values,
lines: ReadonlyMap<string, unknown> = new Map(),
path: ReadonlySet<string> = new Set(),
): boolean {
if (!n) return false;
let at = n;
let hop = 0;
for (let pick = takenOf(at, vals); pick; pick = takenOf(at, vals), hop++)
at = (at as ["call", string, Node[]])[2][pick];
if (hop && at[0] === "num") return at[1] === 0;
const off = (x: Node): boolean => offZero(x, vals, lines, path);
switch (at[0]) {
case "var":
return (
lines.has(at[1]) &&
!path.has(at[1]) &&
offZero(tryParse(lines.get(at[1])), vals, lines, new Set([...path, at[1]]))
);
case "neg":
case "pct":
return off(at[1]);
case "bin":
if (at[1] === "*") return off(at[2]) || off(at[3]);
if (at[1] === "/") return off(at[2]);
if (at[1] === "+" || at[1] === "-") return off(at[2]) && off(at[3]);
return false;
default:
return false;
}
}
/** 식 줄 표 {기호: 식} — `offLine` 이 계산 변수를 거칠 때 */
export const lineMap = (doc: { 식?: { 기호?: unknown; 식?: unknown }[] }): Map<string, unknown> =>
new Map((doc.식 ?? []).map((l) => [String(l.기호 ?? ""), l.식]));
/** 선택으로 꺼진 금액 줄 — 수량 · 단가가 선택으로 꺼진 0(`offZero` · 고침 모드에서만 보임) */
export function offLine(
line: { 수량?: unknown; 단가?: unknown },
vals: Values,
lines: ReadonlyMap<string, unknown> = new Map(),
): boolean {
return [line.수량, line.단가].some((t) => offZero(tryParse(t), vals, lines));
}
/** 만약 — 탄 갈래 차례(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;
}
+5
View File
@@ -584,6 +584,11 @@ app.include_router(
__import__("M02_MasterTemplete.M02_Doc_Router", fromlist=["router"]).router,
dependencies=protected,
)
# 단가산출(SK) 수식 탭 연결 후보 · 표(PLAN 802-4b-2) — 로그인 · 층 읽기 권한은 길 안에서
app.include_router(
__import__("M02_MasterTemplete.M02_Doc_Screen", fromlist=["router"]).router,
dependencies=protected,
)
# 회사에 올리기 요청 · 승인(PLAN 706-1) — 로그인만 · 층 권한은 길 안에서
app.include_router(
__import__("M02_MasterTemplete.M02_Share_Request", fromlist=["router"]).router,
+38
View File
@@ -0,0 +1,38 @@
/* 802-4b-2 꺼진 줄 헬퍼 — common_util_formula_doc_view.ts 를 그 자리에서 트랜스파일해 Node 로 돌림.
* stdin = [{문서, 값}] · stdout = [{새: [꺼진 금액 줄 차례], 옛: [만약 끝 0 만]}] */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const cache = new Map();
function load(at) {
if (cache.has(at)) return cache.get(at);
const js = ts.transpileModule(fs.readFileSync(at, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
cache.set(at, box.exports);
const need = (p) =>
p.startsWith(".") ? load(path.resolve(path.dirname(at), `${p}.ts`)) : require(p);
new Function("exports", "module", "require", js)(box.exports, box, need);
return box.exports;
}
const view = load(
path.join(__dirname, "..", "..", "common_util", "common_util_formula_doc_view.ts"),
);
const input = JSON.parse(fs.readFileSync(0, "utf8"));
const out = input.map(({ 문서, 값 }) => {
const steps = view.lineMap(문서);
const lines = 문서.금액 || [];
const pick = (f) => lines.flatMap((l, i) => (f(l) ? [i] : []));
return {
새: pick((l) => view.offLine(l, 값, steps)),
옛: pick((l) =>
[l.수량, l.단가].some((t) => {
const n = view.tryParse(t);
return !!n && view.leafNum(n, 값) === 0;
}),
),
};
});
process.stdout.write(JSON.stringify(out));
@@ -111,9 +111,8 @@ def test_단산_줄_길_잇기():
assert "skPick(sk" in imp and "o.onLink" in imp # [이 줄에 잇기] = 줄 고른 것 SK
for how in ('run("price"', 'run("link"', 'run("to-base"'):
assert how in imp, how
# 수식 탭 위 줄 = 가져온 식이면 「엑셀 원래」(799-2 — 시트 탭 걷음)
sheet = _src("Sangeun_Equation")
assert 'Excel: ["엑셀 원래"' in sheet and '"가져온" in ans' in sheet
# 옛 M02 수식 나무 화면(「엑셀 원래」 위 줄) 걷음 — 수식 탭 = 공용 수식 문서 화면(802-4b-2)
assert not (M02 / "M02_MasterTemplete_Sangeun_Equation.ts").exists()
locale = (ROOT / "ui_template/ui_template_locale_m2_judge.ts").read_text(encoding="utf-8")
assert 'M02_CheckImported: ["가져온 식으로 열기"' in locale
+2 -4
View File
@@ -178,14 +178,12 @@ def test_m01_change_kept_regen_reapplies(world, monkeypatch):
def test_screen_same_part_first_tab():
"""화면 — M02 수식 = M01 수식 탭 부품 다시 씀(복사 없음) · 첫 탭 · 옛 탭 걷음(799-2) · 저장 = 페이지."""
"""화면 — M02 수식 = 공용 수식 문서 화면(802-4b-2 · 옛 수식 나무 화면 걷음) · 첫 탭 · 옛 탭 걷음(799-2)."""
import re
from pathlib import Path
root = Path(__file__).resolve().parents[2] / "M02_MasterTemplete"
mine = (root / "M02_MasterTemplete_Sangeun_Equation.ts").read_text(encoding="utf-8")
assert "createLabEquation(" in mine and "fetch(" not in mine and "createEqChain" not in mine
assert "/sangeun/equation${suffix}" in mine and "/sangeun/copy/${how}" in mine
assert not (root / "M02_MasterTemplete_Sangeun_Equation.ts").exists()
tabs = (root / "M02_MasterTemplete_Sangeun_Tabs.ts").read_text(encoding="utf-8")
views = re.findall(r'\{ value: "(\w+)", label: ', tabs)
# 799-2 — 시트 · 인력·재료·경비 · 시험 계산 · 텍스트 수식 걷음
+227
View File
@@ -0,0 +1,227 @@
# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀
"""802-4b-2 M02 단가산출(SK) 수식 탭 = 공용 수식 문서 화면 — 임시 storage · M01 은 메모리 덮개.
① 길 앞머리 — M02 수식 탭 = 공용 화면(`createDocScreen` · `/m02/layers/{층}/sangeun/{키}/doc`) ·
옛 M02 수식 나무 화면 걷음 · 연결 후보 · 표 길 등록
② 읽기 연결 기록만으로 같은 셈(화면이 네트워크 없이 다시 셈) — 끊은 단가산출 = 끊음 문서 먼저
③ 권한 — 회사 층 일반 계정 = 읽기 · 후보 · 표만(미리 셈 · 저장 403) · 화면 = 읽기만(옵션 잠금)
④ 꺼진 줄 — 만약 0 갈래가 계산 변수를 거쳐 와도 꺼짐(폼타이 「아님」 = 세 줄) · 입력 0 안 숨김 ·
1,518 문서 기본 옵션 — 옛 판정 ⊆ 새 판정 · 꺼진 줄 금액 모두 0
"""
from __future__ import annotations
import copy
import json
import os
import subprocess
from decimal import Decimal
from pathlib import Path
import pytest
from fastapi import HTTPException
from M02_MasterTemplete import M02_Doc_Copy as dc
from M02_MasterTemplete import M02_Doc_Router as router
from M02_MasterTemplete import M02_Doc_Screen as screen
from M02_MasterTemplete import M02_Doc_Sk_Path as path
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from resources.tester.test_795_1_sk_copy import _sk
from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층
from resources.tester.test_m02_structure import write_sample_table
ROOT = Path(__file__).resolve().parents[2]
HERE = os.path.dirname(os.path.abspath(__file__))
FORM = "GC000211" # 합판거푸집 — 폼타이 · 측압 · 규격 옵션
BASIC = "GC001148" # 굴착기 시간당
ADD = {"종류": "로직", "로직": BASIC, "수량": 2, "입력값": {}, "이름": "sub3임시 굴착기"}
def _at(folder, fn, *args):
with store.at(folder):
return fn(*args)
def _src(rel: str) -> str:
return (ROOT / rel).read_text(encoding="utf-8")
def test_길_앞머리_M02_공용화면():
tabs = _src("M02_MasterTemplete/M02_MasterTemplete_Sangeun_Tabs.ts")
assert "createDocScreen({" in tabs and "copy: { route: (k, p) => skDocPath(" in tabs
assert "/m02/layers/${scope.layer}/sangeun/${encodeURIComponent(key)}/doc${tail}" in tabs
assert "admin: opt.edit" in tabs # 층 쓰기 권한 = 고침
scr = _src("A00_Common/formula_doc_screen.ts")
assert "cp ? cp.route(key, path) : opt.base + path" in scr
assert "{ 문서: doc, 끊음: cut, 판: version }" in scr # 저장 몸
detail = _src("M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts")
assert 'tabs.draw(/^SK-\\d{6}$/.test(doc.키 ?? "")' in detail # 저장한 SK 만 · 줄 모달 = 글
assert not (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Sangeun_Equation.ts").exists()
main = _src("main.py")
assert '__import__("M02_MasterTemplete.M02_Doc_Screen", fromlist=["router"]).router' in main
for name in ("formula_doc_screen", "formula_doc_sub", "formula_doc_copy", "formula_doc_edit"):
assert len(_src(f"A00_Common/{name}.ts").splitlines()) <= 700, name
class _Replay:
"""화면 캐시 흉내 — 서버 연결 기록만으로 셈(`common_util_formula_doc_cache.ts` 짝)."""
def __init__(self, rec: dict):
self.rec = rec
self.els = {self._k(e["p"]): e for e in rec.get("요소") or []}
@staticmethod
def _k(p) -> str:
return json.dumps(p, ensure_ascii=False, sort_keys=True, default=str)
def element(self, p):
got = self.els[self._k(dc.ml._norm(p))]
if "빈" in got:
raise dc.mc.Gap(got["빈"])
if "끊김" in got:
raise dc.mc.Broken(got["끊김"])
return Decimal(str(got["값"])) if isinstance(got["값"], (int, float)) else got["값"]
def table(self, p):
return dc.ml.Links(_Tables(self.rec["표"]), {}).table(p)
def doc(self, key):
d = (self.rec.get("문서") or {}).get(key)
return json.loads(json.dumps(d), parse_float=Decimal, parse_int=Decimal) if d else d
class _Tables:
"""기록 표만 든 색인(`Links.table` 이 `master.get` 으로 찾음)."""
def __init__(self, tables: dict):
self.tables = tables
def get(self, ref):
return json.loads(json.dumps(self.tables[ref]), parse_float=Decimal, parse_int=Decimal)
def _replay(read: dict) -> dict:
doc = json.loads(json.dumps(read["문서"]), parse_float=Decimal, parse_int=Decimal)
return dc.mc.calc(doc, _Replay(read["연결"]))
def test_연결기록만으로_같은셈_끊음먼저(world):
me = world["personal"]
key = _sk(
me,
바탕={"로직": FORM},
입력값={"폼타이": "사용", "측압": "4t/㎡"},
더한줄=[copy.deepcopy(ADD)],
)
read = _at(me, path.read, key)
rec = read["연결"]
assert {"요소", "표", "문서", "이름"} <= set(rec) and BASIC in rec["문서"]
assert _replay(read)["계"] == read["셈"]["계"]
cut = copy.deepcopy(read["부름"][BASIC])
line = cut["금액"][0]
line["수량"] = f"({line.get('수량') or 1}) × 2" # 끊은 문서 안 줄 고침
saved = _at(me, path.save, key, {"끊음": {BASIC: cut}, "판": read["판"]})
assert saved["연결"]["문서"][BASIC]["금액"][0]["수량"] == line["수량"] # 끊음 먼저
assert saved["셈"]["계"] > read["셈"]["계"]
assert _replay(saved)["계"] == saved["셈"]["계"]
again = _at(me, path.read, key)
assert again["끊음"][BASIC]["금액"][0]["수량"] == line["수량"] and again["판"] == saved["판"]
got = _at(me, path.save, key, {"끊음": {}, "판": again["판"]}) # 다시 잇기
assert got["끊음"] == {} and got["셈"]["계"] == read["셈"]["계"]
def _session(role="USER"):
return {"role": role, "company_id": 7, "user_id": 42}
def test_권한_회사층_일반계정_읽기만(world):
company = world["company"]
company.mkdir(parents=True, exist_ok=True)
write_sample_table(company)
with store.at(company):
store.create_structure("rv")
key = _sk(company, 바탕={"로직": FORM})
read = router.read_doc("company", key, None, _session())
assert read["셈"]["셈됨"] and read["연결"]["표"]
body = router.DocBody(문서=read["문서"])
for fn in (router.preview_doc, router.save_doc):
with pytest.raises(HTTPException) as e:
fn("company", key, body, None, _session())
assert e.value.status_code == 403
found = screen.doc_links(
"company", key, screen.LinksBody(갈래="단가산출", 검색="굴착기 시간당"), None, _session()
)
assert any(c["키"] == "GC000267" for c in found["후보"])
assert "줄" in screen.doc_table("company", key, "QC000431", None, _session())
scr = _src("A00_Common/formula_doc_screen.ts")
assert "buildOptions(doc, given, !!cp && !opt.admin," in scr # 사본 읽기만 = 옵션 잠금
copy_src = _src("A00_Common/formula_doc_copy.ts")
assert "if (!edit) return [];" in copy_src and 'createBadge(ft("Read_Only")' in copy_src
# ── ④ 꺼진 줄(TS 판정 그대로 · Node) ─────────────────────────────────
def _off(cases: list[dict]) -> list[dict]:
proc = subprocess.run(
["node", os.path.join(HERE, "helper_802_4b2_off.cjs")],
input=json.dumps(dc.ml._norm(cases), ensure_ascii=False),
capture_output=True,
text=True,
encoding="utf-8",
check=True,
)
return json.loads(proc.stdout)
def _vals(doc: dict, given: dict | None = None) -> dict:
links = dc.ml.Links(dc.skc.whole_(), dc.folder())
return dc.mc.calc(doc, links, given).get("값") or {}
def test_꺼진줄_계산변수_거침():
doc = dc.folder()[FORM]
names = [x["이름"] for x in doc["금액"]]
tie = [i for i, n in enumerate(names) if n.startswith("폼타이")]
hole = [i for i, n in enumerate(names) if "구멍땜" in n]
assert len(tie) == 3 and hole
off, on, on2 = _off(
[
{"문서": doc, "값": _vals(doc)}, # 폼타이 아님 · 규격 12.7
{"문서": doc, "값": _vals(doc, {"폼타이": "사용"})},
{"문서": doc, "값": _vals(doc, {"폼타이": "사용", "폼타이규격": "9.5㎜"})},
]
)
assert set(tie) <= set(off["새"]) and tie[0] not in off["옛"] # Q_조 = 만약 0 을 거침
assert set(on["새"]) == set(tie) - {tie[0]} and set(on2["새"]) == set(tie) - {tie[1]}
assert not set(hole) & set(off["새"]) # 구멍땜 0 개소 = 입력 0 · 안 숨김
plain = {
"식": [{"기호": "Z", "식": "0"}, {"기호": "Y", "식": "만약(S == 'a', 0, 2)"}],
"금액": [
{"이름": "글 0", "비목": "노무비", "수량": "Z × 3", "단가": "w"},
{"이름": "입력 0", "비목": "노무비", "수량": "X × 3", "단가": "w"},
{"이름": "거침", "비목": "노무비", "수량": "Y × 3 ÷ 10", "단가": "w"},
{"이름": "합", "비목": "노무비", "수량": "Y + 1", "단가": "w"},
{"이름": "순환", "비목": "노무비", "수량": "V", "단가": "w"},
],
}
plain["식"].append({"기호": "V", "식": "V × 2"})
got = _off([{"문서": plain, "값": {"S": "a", "Z": 0, "X": 0, "Y": 0, "w": 5}}])[0]
assert got["새"] == [2]
def test_꺼진줄_전수_1518():
docs = dc.folder()
links = dc.ml.Links(dc.skc.whole_(), docs)
keys, cases, rows = [], [], []
for key, doc in docs.items():
ans = dc.mc.calc(doc, links)
keys.append(key)
cases.append({"문서": doc, "값": ans.get("값") or {}})
rows.append(ans.get("줄") or [])
got = _off(cases)
assert len(keys) >= 1518
new = sum(len(g["새"]) for g in got)
assert all(set(g["옛"]) <= set(g["새"]) for g in got) and new > sum(len(g["옛"]) for g in got)
for key, g, r in zip(keys, got, rows):
for i in g["새"]:
amt = r[i].get("금액") if i < len(r) else None
assert amt is None or float(amt) == 0, (key, i, amt)