From c80b5ae9d05189c15978d53c222bc492d82c737d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 7 Nov 2026 20:20:52 +0900 Subject: [PATCH] =?UTF-8?q?feat(802-4b-2):=20M02=20=EB=8B=A8=EA=B0=80?= =?UTF-8?q?=EC=82=B0=EC=B6=9C=20=EC=88=98=EC=8B=9D=20=ED=83=AD=20=3D=20?= =?UTF-8?q?=EA=B3=B5=EC=9A=A9=20=EC=88=98=EC=8B=9D=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=C2=B7=20=EA=BA=BC=EC=A7=84=20=EC=A4=84=20?= =?UTF-8?q?=3D=20=EA=B3=84=EC=82=B0=20=EB=B3=80=EC=88=98=20=EA=B1=B0?= =?UTF-8?q?=EC=B9=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- A00_Common/formula_doc_copy.ts | 89 +++++ A00_Common/formula_doc_edit.ts | 92 +++++ A00_Common/formula_doc_lines.ts | 4 +- A00_Common/formula_doc_options.ts | 55 +++ A00_Common/formula_doc_render.ts | 112 +----- A00_Common/formula_doc_screen.ts | 343 +++++++++--------- A00_Common/formula_doc_sub.ts | 147 ++++++++ A00_Common/formula_doc_text.ts | 27 ++ M02_MasterTemplete/M02_Doc_Screen.py | 69 ++++ M02_MasterTemplete/M02_Doc_Sk_Path.py | 4 +- .../M02_MasterTemplete_Sangeun_Detail.ts | 31 +- .../M02_MasterTemplete_Sangeun_Equation.ts | 286 --------------- .../M02_MasterTemplete_Sangeun_Tabs.ts | 53 ++- .../M02_MasterTemplete_Sangeun_View.ts | 2 + .../M02_MasterTemplete_UI_Main.ts | 4 +- common_util/common_util_formula_doc_view.ts | 139 +++++++ main.py | 5 + resources/tester/helper_802_4b2_off.cjs | 38 ++ resources/tester/test_793_3b_import_screen.py | 5 +- resources/tester/test_796_2b_m02_equation.py | 6 +- resources/tester/test_802_4b2_m02_screen.py | 227 ++++++++++++ 21 files changed, 1146 insertions(+), 592 deletions(-) create mode 100644 A00_Common/formula_doc_copy.ts create mode 100644 A00_Common/formula_doc_edit.ts create mode 100644 A00_Common/formula_doc_options.ts create mode 100644 A00_Common/formula_doc_sub.ts create mode 100644 M02_MasterTemplete/M02_Doc_Screen.py delete mode 100644 M02_MasterTemplete/M02_MasterTemplete_Sangeun_Equation.ts create mode 100644 common_util/common_util_formula_doc_view.ts create mode 100644 resources/tester/helper_802_4b2_off.cjs create mode 100644 resources/tester/test_802_4b2_m02_screen.py diff --git a/A00_Common/formula_doc_copy.ts b/A00_Common/formula_doc_copy.ts new file mode 100644 index 000000000..b5ac3e499 --- /dev/null +++ b/A00_Common/formula_doc_copy.ts @@ -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 | 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): Promise { + 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")); +} diff --git a/A00_Common/formula_doc_edit.ts b/A00_Common/formula_doc_edit.ts new file mode 100644 index 000000000..75efa6f44 --- /dev/null +++ b/A00_Common/formula_doc_edit.ts @@ -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 | 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)[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); +} diff --git a/A00_Common/formula_doc_lines.ts b/A00_Common/formula_doc_lines.ts index e53224901..8b599accf 100644 --- a/A00_Common/formula_doc_lines.ts +++ b/A00_Common/formula_doc_lines.ts @@ -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[]; diff --git a/A00_Common/formula_doc_options.ts b/A00_Common/formula_doc_options.ts new file mode 100644 index 000000000..cdc35169f --- /dev/null +++ b/A00_Common/formula_doc_options.ts @@ -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, + 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; +} diff --git a/A00_Common/formula_doc_render.ts b/A00_Common/formula_doc_render.ts index 04127581d..1b87d2fd6 100644 --- a/A00_Common/formula_doc_render.ts +++ b/A00_Common/formula_doc_render.ts @@ -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; +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 = { "*": "×", @@ -48,94 +62,6 @@ const PREC: Record = { }; 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.단가); } diff --git a/A00_Common/formula_doc_screen.ts b/A00_Common/formula_doc_screen.ts index bc263e91d..e2518506a 100644 --- a/A00_Common/formula_doc_screen.ts +++ b/A00_Common/formula_doc_screen.ts @@ -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; + interface Loaded { 문서: FormulaDoc; 셈: DocAnswer; 연결: DocRecord; 새?: boolean; + 부름?: Docs; + 끊음?: Docs; + 사본?: CopyMark; + 판?: string; } const clone = (v: T): T => JSON.parse(JSON.stringify(v)) as T; -/** 머리 = 식 — 첫 「=」(비교 ==, <=, >=, != 빼고) */ -export function splitLine(text: string): [string | null, string] { - const m = /^([^=<>!]*?)\s*(?!=])=(?!=)\s*([\s\S]*)$/.exec(text); - if (!m) return [null, text.trim()]; - return [m[1].trim(), m[2].trim()]; -} - export function createDocScreen(opt: DocScreenOptions) { const head = el("div", { className: "fdoc__head" }); const marks = el("div", { className: "fdoc__marks" }); @@ -92,9 +106,15 @@ export function createDocScreen(opt: DocScreenOptions) { let ctl: AbortController | null = null; let waiting = false; const tables = new Map>(); + const cp = opt.copy; + let cut: Docs = {}; // 사본 — 끊은 단가산출 문서(이 사본 안 · 고칠 수 있음) + let savedCut = "{}"; + let calls: Docs = {}; // 사본 — 부름 문서(다시 잇기 = 이 문서로) + let mark: CopyMark | undefined; + let version = ""; const call = async (path: string, body?: unknown, signal?: AbortSignal): Promise => { - 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 | null): void => change((d) => { - const lines = (d.금액 ??= []); - if (!patch) { - lines.splice(i, 1); - return; - } - const line = lines[i]; - for (const [k, v] of Object.entries(patch)) { - const text = typeof v === "string" && (k === "단가" || k === "수량") ? tidyText(v) : v; - if (k === "수량" && text === "") delete line.수량; - else (line as Record)[k] = text; - } - if ("이름" in patch && !line.단가) auto = { kind: "price", i }; - }); - - const onLit = (target: "line" | "price" | "qty", i: number, at: number, value: number): void => - change((d) => { - if (target === "line") d.식![i].식 = swapLit(String(d.식![i].식 ?? ""), at, value); - else { - const line = d.금액![i]; - const slot = target === "price" ? "단가" : "수량"; - line[slot] = swapLit(String(line[slot] ?? ""), at, value); - } + 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 = {}; - 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 => { + 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 => { + const body = (): Record => + cp ? { 문서: doc, 끊음: cut, 판: version } : { 문서: doc }; + + /** 사본 [단가 갱신] · [재생성] — 미리 셈 → 알림 → 저장(고친 것 먼저 저장 · 버림) */ + const runJob = async (job: CopyJob): Promise => { + if (!doc || !cp) return; + if (dirty()) return void showToast(ft("Dirty_First"), "warning"); + showLoadingOverlay(); + let got: JobReply; + try { + got = await call("/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 => { if (!doc) return; showLoadingOverlay(); try { - const got = await call("/save", { 문서: doc }); + const got = await call("/save", send).catch(async (e: { status?: number }) => { + // 사본 판 바뀜(페이지 [저장] 등) — 수식 사본이 그대로면 새 판으로 한 번 더 + if (!cp || e.status !== 409) throw e; + const now = await call(""); + if ( + JSON.stringify([now.문서, now.끊음 ?? {}]) !== + JSON.stringify([JSON.parse(saved), JSON.parse(savedCut)]) + ) + throw e; + return call("/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 => { 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(`?key=${encodeURIComponent(next)}`); + const got = await call(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 => { if (!dirty()) return true; if (!(await showConfirmDialog(ft("Leave"), ft("Leave_Ok")))) return false; - doc = JSON.parse(saved) as FormulaDoc; + back(); keep(); return true; }; diff --git a/A00_Common/formula_doc_sub.ts b/A00_Common/formula_doc_sub.ts new file mode 100644 index 000000000..a2e0adfd3 --- /dev/null +++ b/A00_Common/formula_doc_sub.ts @@ -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 = {}; + 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] }); +} diff --git a/A00_Common/formula_doc_text.ts b/A00_Common/formula_doc_text.ts index af0134ec7..171f1d378 100644 --- a/A00_Common/formula_doc_text.ts +++ b/A00_Common/formula_doc_text.ts @@ -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; diff --git a/M02_MasterTemplete/M02_Doc_Screen.py b/M02_MasterTemplete/M02_Doc_Screen.py new file mode 100644 index 000000000..e6ad52a01 --- /dev/null +++ b/M02_MasterTemplete/M02_Doc_Screen.py @@ -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) diff --git a/M02_MasterTemplete/M02_Doc_Sk_Path.py b/M02_MasterTemplete/M02_Doc_Sk_Path.py index 518c6c65b..690ec71d2 100644 --- a/M02_MasterTemplete/M02_Doc_Sk_Path.py +++ b/M02_MasterTemplete/M02_Doc_Sk_Path.py @@ -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)) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts index 26840a2d5..283be09d7 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts @@ -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 { + 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 — 일위대가 줄 모달 [적용] = 옵션을 그 줄에 남김 diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Equation.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Equation.ts deleted file mode 100644 index 74c4ddf09..000000000 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Equation.ts +++ /dev/null @@ -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; -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 | null; - 가져온?: unknown; -}; -interface CopyReply { - 문서: SkDoc; - 전: Record | null; - 후: Record | 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; -} - -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 | undefined => - doc().더한줄?.[k] as Record | undefined; - const post = (path: string, body: unknown, signal?: AbortSignal) => - call(`/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 | 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 { - try { - const got = await call(`/layers/${layer}/sangeun/copy/${how}${q}`, { - method: "POST", - body: JSON.stringify({ 문서: doc(), 입력값: last?.values ?? {} }), - }); - doc()[JOB] = got.문서[JOB]; - const sum = (v: Record | 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 { - const ask = t(how === "unlink" ? "AskUnlink" : "AskRelink"); - if (!(await showConfirmDialog(ask, t(how === "unlink" ? "Unlink" : "Relink")))) return; - try { - const got = await post(`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>(); - function blockLab(k: number): ReturnType { - 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 | 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); - }), - }; -} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Tabs.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Tabs.ts index 6b92501f2..643b4ba7f 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Tabs.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Tabs.ts @@ -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; } { - 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 }; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_View.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_View.ts index 4a3b91584..555a73fbf 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_View.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_View.ts @@ -11,6 +11,8 @@ import type { Selection } from "./M02_MasterTemplete_UI_Main"; export type SangeunViewOptions = Pick & { readOnly: boolean; onChanged: (dirty: boolean) => void; + /** 수식 탭 저장 뒤 그 SK 새 판(802-4b-2) */ + onVersion?: (version: string) => void; }; export function mountSangeunView( diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index cde030267..90da9d89d 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -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") { diff --git a/common_util/common_util_formula_doc_view.ts b/common_util/common_util_formula_doc_view.ts new file mode 100644 index 000000000..456999f4a --- /dev/null +++ b/common_util/common_util_formula_doc_view.ts @@ -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; + +/** 식 글 → 나무(못 풀면 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 = new Map(), + path: ReadonlySet = 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 => + new Map((doc.식 ?? []).map((l) => [String(l.기호 ?? ""), l.식])); + +/** 선택으로 꺼진 금액 줄 — 수량 · 단가가 선택으로 꺼진 0(`offZero` · 고침 모드에서만 보임) */ +export function offLine( + line: { 수량?: unknown; 단가?: unknown }, + vals: Values, + lines: ReadonlyMap = 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; +} diff --git a/main.py b/main.py index 7a6eeaaee..51e4b6363 100644 --- a/main.py +++ b/main.py @@ -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, diff --git a/resources/tester/helper_802_4b2_off.cjs b/resources/tester/helper_802_4b2_off.cjs new file mode 100644 index 000000000..988fce9ab --- /dev/null +++ b/resources/tester/helper_802_4b2_off.cjs @@ -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)); diff --git a/resources/tester/test_793_3b_import_screen.py b/resources/tester/test_793_3b_import_screen.py index 3a0071bbb..f69f94197 100644 --- a/resources/tester/test_793_3b_import_screen.py +++ b/resources/tester/test_793_3b_import_screen.py @@ -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 diff --git a/resources/tester/test_796_2b_m02_equation.py b/resources/tester/test_796_2b_m02_equation.py index 3d1f6410c..5bddc1dcf 100644 --- a/resources/tester/test_796_2b_m02_equation.py +++ b/resources/tester/test_796_2b_m02_equation.py @@ -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 — 시트 · 인력·재료·경비 · 시험 계산 · 텍스트 수식 걷음 diff --git a/resources/tester/test_802_4b2_m02_screen.py b/resources/tester/test_802_4b2_m02_screen.py new file mode 100644 index 000000000..f97bd985a --- /dev/null +++ b/resources/tester/test_802_4b2_m02_screen.py @@ -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)