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:
@@ -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"));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>[];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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] });
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
@@ -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") {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 — 시트 · 인력·재료·경비 · 시험 계산 · 텍스트 수식 걷음
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user