diff --git a/A00_Common/formula_doc_copy.ts b/A00_Common/formula_doc_copy.ts
index fd5107cb1..1b74e2e7c 100644
--- a/A00_Common/formula_doc_copy.ts
+++ b/A00_Common/formula_doc_copy.ts
@@ -14,7 +14,9 @@ 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 type { FormulaDoc } from "../common_util/common_util_formula_doc";
import type { Basis } from "../common_util/common_util_formula_doc_import";
+import { swappedCount } from "../common_util/common_util_line_stack_pick";
import { ft } from "./formula_doc_text";
export type CopyJob = "단가갱신" | "재생성";
@@ -77,6 +79,15 @@ export function copyBadges(m: CopyMark | undefined, edit: boolean): HTMLElement[
return out;
}
+/** 가져온 식 바꾼 줄 배지(823-1) — 「바꾼 줄 n — 원본과 다름」 · 없으면 없음 */
+export function swapBadge(doc: FormulaDoc): HTMLElement[] {
+ const n = swappedCount(doc);
+ if (!n) return [];
+ const b = createBadge(ft("Swapped_N").replace("{n}", String(n)), "info");
+ b.dataset.swappedN = String(n);
+ return [b];
+}
+
export function copyTools(edit: boolean, run: (job: CopyJob) => void): HTMLElement[] {
if (!edit) return [];
return (["단가갱신", "재생성"] as const).map((job) => {
diff --git a/A00_Common/formula_doc_text.ts b/A00_Common/formula_doc_text.ts
index 010f39b62..dd7607219 100644
--- a/A00_Common/formula_doc_text.ts
+++ b/A00_Common/formula_doc_text.ts
@@ -67,6 +67,7 @@ const TEXT = {
Gone: ["지워짐", "Deleted"],
Gone_Note: ["마지막 값 유지", "last value kept"],
Gap: ["사본 없는 줄", "Uncopied rows"],
+ Swapped_N: ["바꾼 줄 {n} — 원본과 다름", "{n} swapped — differs from source"],
Gap_Note: ["저장하면 지금 값으로 굳음", "saving freezes current values"],
Price: ["단가 갱신", "Refresh prices"],
Regen: ["재생성", "Regenerate"],
diff --git a/A00_Common/line_stack_edit.ts b/A00_Common/line_stack_edit.ts
index bea153692..224846c8a 100644
--- a/A00_Common/line_stack_edit.ts
+++ b/A00_Common/line_stack_edit.ts
@@ -42,6 +42,7 @@ import {
repick,
shownUnit,
tableAlias,
+ unswap,
} from "../common_util/common_util_line_stack_pick";
import { createUnitPick, NO_UNIT } from "../M02_MasterTemplete/DataFlow/M02_DF_UnitPick";
import { reach } from "./formula_doc_render";
@@ -176,6 +177,24 @@ function moneyBody(h: EditHooks, i: number): HTMLElement[] {
head.push(pick);
const mark = machineMark(found, link);
if (mark) head.push(mark);
+ // 823-1 가져온 줄 바꿔 끼움 = 우리 값 · 「바꿈」 + 되돌리기(원래 것 · 엑셀 값)
+ const sw = (link as { 바꿈?: { 이름?: string; 규격?: string } }).바꿈;
+ if (sw) {
+ const was = `${sw.이름 ?? ""} ${sw.규격 ?? ""}`.trim();
+ const tag = createBadge(lt("Swapped"), "info", { size: 18 });
+ tag.title = `${lt("Swapped_Note")} — ${was}`;
+ tag.dataset.swapped = String(i);
+ const back = createButton({
+ label: lt("Unswap"),
+ variant: "ghost",
+ size: 24,
+ iconStart: "undo",
+ onClick: () => h.change((doc) => unswap(doc, i, own)),
+ });
+ back.title = was;
+ back.dataset.unswap = String(i);
+ head.push(tag, back);
+ }
// 821-3 품셈 기계 줄 → 복사해 내 기계로(개인 층 SK)
const mine =
kind === "기계"
diff --git a/A00_Common/line_stack_screen.ts b/A00_Common/line_stack_screen.ts
index e9727b88f..8ae8b8bfe 100644
--- a/A00_Common/line_stack_screen.ts
+++ b/A00_Common/line_stack_screen.ts
@@ -45,6 +45,7 @@ import {
type CopyMark,
type CopyOptions,
type JobReply,
+ swapBadge,
} from "./formula_doc_copy";
import { docFoot } from "./formula_doc_foot";
import { buildOptions } from "./formula_doc_options";
@@ -317,6 +318,7 @@ export function createStackScreen(opt: StackScreenOptions) {
children: [
...(busy ? [createBadge(ft("Dirty"), "warning")] : []),
...(cp ? copyBadges(mark, opt.admin) : []),
+ ...(mark?.단가기준 ? swapBadge(doc) : []), // 823-1 가져온 줄 바꿔 끼움
],
}),
el("div", { className: "lstk__tools", children: opt.tools ? [] : tools }),
diff --git a/A00_Common/line_stack_text.ts b/A00_Common/line_stack_text.ts
index 2c3191e3a..88476c37a 100644
--- a/A00_Common/line_stack_text.ts
+++ b/A00_Common/line_stack_text.ts
@@ -78,6 +78,12 @@ const TEXT = {
Tbl_Missing: ["표를 못 읽음", "Table not found"],
Mach_Many: ["기계 후보 여럿 — 고르기", "Several machines — pick one"],
Mach_None: ["기계 못 찾음 — 고르기", "Machine not found — pick one"],
+ Swapped: ["바꿈", "Swapped"],
+ Swapped_Note: [
+ "가져온 줄을 바꿔 끼움 — 이 줄만 우리 값",
+ "Imported line swapped — our price on this line",
+ ],
+ Unswap: ["되돌리기", "Restore"],
Mach_Combo: ["조합 기계 — 경비는 첫 줄", "Combined machine — cost on first line"],
Tbl_Hint: ["값 칸을 누르면 그 값이 들어감", "Click a value cell to use it"],
Tbl_Bound: [
diff --git a/M02_MasterTemplete/M02_Doc_Gap.py b/M02_MasterTemplete/M02_Doc_Gap.py
index b8e22a8e5..af21c0afc 100644
--- a/M02_MasterTemplete/M02_Doc_Gap.py
+++ b/M02_MasterTemplete/M02_Doc_Gap.py
@@ -64,3 +64,19 @@ def fill(copy: dict) -> dict:
if not keys:
return copy
return {**copy, **skc.grow(dc.values_of(copy), skc.snap([], keys))}
+
+
+def fill_imported(sk: dict, doc: Any) -> dict:
+ """가져온 식 SK(823-1) — 문서 셈(단가기준 · 바꿔 끼운 줄)이 읽은 줄 가운데
+ SK 로직 사본에 없는 것 + 내 기계 = 지금 값으로 로직 사본에 더함 ·
+ 사본 없음 · 빈틈 없음 = 그대로."""
+ from M02_MasterTemplete import M02_Doc_Import as dim
+
+ if not skc.has(sk) or not isinstance(doc, dict):
+ return sk
+ seen = _Seen(dim._index(sk, None, doc))
+ dim.run(doc, sk, index=seen) # 셈 틀림 = 읽은 데까지(run 이 받음)
+ keys = sorted({*seen.keys, *mym.keys_in(doc)} - _have(sk[skc.KEY]))
+ if not keys:
+ return sk
+ return {**sk, skc.KEY: skc.grow(sk[skc.KEY], skc.snap([], keys))}
diff --git a/M02_MasterTemplete/M02_Doc_Import.py b/M02_MasterTemplete/M02_Doc_Import.py
index 96d2084b8..511a943a9 100644
--- a/M02_MasterTemplete/M02_Doc_Import.py
+++ b/M02_MasterTemplete/M02_Doc_Import.py
@@ -51,12 +51,18 @@ def basis_of(sk: dict) -> str:
return "우리" if (sk.get(imp.KEY) or {}).get("단가기준") == "우리" else "엑셀"
-def _index(sk: dict, master: Any = None) -> Any:
- """셈 색인 — 우리 = 로직 사본 위(822-1 · 이음 값이 M01 따라 안 바뀜 · [단가 갱신] 으로만) ·
- 엑셀 = 받은 색인 · 없으면 지금 M01."""
+def swapped(doc: Any) -> int:
+ """바꿔 끼운 가져온 줄 수(823-1 · 연결 `바꿈` = 원래 · 엑셀 값 뗌 = 우리 값)."""
+ vars_ = (doc or {}).get("변수") or [] if isinstance(doc, dict) else []
+ return sum(1 for v in vars_ if isinstance(v.get("연결"), dict) and v["연결"].get("바꿈"))
+
+
+def _index(sk: dict, master: Any = None, doc: Any = None) -> Any:
+ """셈 색인 — 우리 · 바꾼 줄 있음(823-1) = 로직 사본 위(822-1 · 이음 값이 M01 따라 안 바뀜 ·
+ [단가 갱신] 으로만) · 엑셀 = 받은 색인 · 없으면 지금 M01."""
from M02_MasterTemplete import M02_Sangeun_Copy as skc
- if basis_of(sk) == "우리":
+ if basis_of(sk) == "우리" or swapped(doc):
return skc.over(sk, imp._whole())
return master or imp._whole()
@@ -524,7 +530,7 @@ def record(doc: dict, sk: dict) -> dict[str, Any]:
import master_doc_record as mr # `M02_Doc_Copy` 가 scripts 를 길에 둔 뒤
basis = basis_of(sk)
- links = Links(_index(sk), basis)
+ links = Links(_index(sk, None, doc), basis)
mine = view(doc, basis)
rec = mr.Rec(links)
try:
@@ -536,10 +542,11 @@ def record(doc: dict, sk: dict) -> dict[str, Any]:
return rec.out()
-def run(doc: dict, sk: dict, master: Any = None) -> dict:
- """가져온 식 문서 셈(SK 단가기준) — 계산기 = M01 과 같은 `master_doc_calc`."""
+def run(doc: dict, sk: dict, master: Any = None, index: Any = None) -> dict:
+ """가져온 식 문서 셈(SK 단가기준) — 계산기 = M01 과 같은 `master_doc_calc` ·
+ `index` = 셈 색인 갈음(빈틈 적기 · 823-1)."""
basis = basis_of(sk)
- links = Links(_index(sk, master), basis)
+ links = Links(index or _index(sk, master, doc), basis)
mine = view(doc, basis)
try:
links.learn(mine)
diff --git a/M02_MasterTemplete/M02_Doc_Sk.py b/M02_MasterTemplete/M02_Doc_Sk.py
index 6e4d6acf7..fa3c5ed75 100644
--- a/M02_MasterTemplete/M02_Doc_Sk.py
+++ b/M02_MasterTemplete/M02_Doc_Sk.py
@@ -49,6 +49,8 @@ def _strip(v: Any) -> Any:
return {k: _strip(x) for k, x in v.items() if k not in _STAMPED}
if isinstance(v, list):
return [_strip(x) for x in v]
+ if isinstance(v, float) and v.is_integer(): # 파일 24554.0 = 화면 왕복 24554(823-1)
+ return int(v)
return v
@@ -188,6 +190,8 @@ def settle(out: dict, whole: Any, job: Any, raw: Any) -> dict:
raw = raw if isinstance(raw, dict) else {}
mine = SAVING.get()
if mine is not None and mine.get("키") == out.get("키"): # 수식 길 저장 — 서버가 지은 사본
+ if dim.is_imported(out): # 가져온 식 — 셈이 읽은 줄(바꿔 끼운 줄 등)을 로직 사본에(823-1)
+ out = gap.fill_imported(out, mine["사본"].get("문서"))
return {**out, KEY: norm(mine["사본"])}
file = stored(out.get("키")) or {}
have = file.get(KEY) if dc.has(file) else (raw.get(KEY) if dc.has(raw) else None)
diff --git a/M02_MasterTemplete/M02_Labor_Ref.py b/M02_MasterTemplete/M02_Labor_Ref.py
index 6afc85727..1afa5198c 100644
--- a/M02_MasterTemplete/M02_Labor_Ref.py
+++ b/M02_MasterTemplete/M02_Labor_Ref.py
@@ -24,6 +24,7 @@ from __future__ import annotations
import json
import re
from collections.abc import Callable
+from contextvars import ContextVar
from pathlib import Path
from typing import TYPE_CHECKING, Any
@@ -125,11 +126,18 @@ def copy_ref(body: dict[str, Any], key: str) -> Ref:
}
+#: 참조 파일을 읽기 직전에 부름(826-1 층 목록 색인 기댐 · `M02_Layer_Index`) — None = 안 부름.
+SEEN: ContextVar[Callable[[Path], None] | None] = ContextVar("m02_refs_seen", default=None)
+
+
def read_ref(ref: Ref) -> dict[str, Any] | None:
if is_copy(ref):
return ref["몸"]
try:
- got = json.loads(ref_path(ref).read_bytes())
+ path = ref_path(ref)
+ if (note := SEEN.get()) is not None:
+ note(path)
+ got = json.loads(path.read_bytes())
except (OSError, ValueError):
return None
return got if isinstance(got, dict) else None
diff --git a/M02_MasterTemplete/M02_Layer_Index.py b/M02_MasterTemplete/M02_Layer_Index.py
new file mode 100644
index 000000000..9203e5d68
--- /dev/null
+++ b/M02_MasterTemplete/M02_Layer_Index.py
@@ -0,0 +1,221 @@
+"""M02 층 목록 요약 색인(826-1 · 요구 804 · 조사 804 안 A) — 층 목록이 항목 파일을 다시 열지 않게.
+
+항목 기록 `{자국, 판, 바탕?, 기댐?, 요약?}`(열쇠 「종류/이름」):
+ 자국 파일 고친 때 ns : 크기 — 같으면 기억한 판(목록 표시만 · 저장 · 지움 409 는 그대로
+ `layers.version_of` 파일 내용)
+ 요약 서고 줄 `M02_Labor_Ref.summary`(쓰는곳수 뺌) — 자국 · 바탕 · 기댐이 모두 같을 때만 씀
+ 바탕 셈 코드 내용 · M01 폴더 · 층 곁 파일(내 기계 등 — 항목 폴더 · manifest 뺌) 자국 지문
+ 기댐 셈 때 읽은 참조 파일(SC · SK) 자국(`refs.SEEN`)
+쓰는곳수 = 요청마다 쓰는 곳 파일을 한 번 훑어 거꾸로 셈(`refs._ids_of` 기억 · 파일당 stat 한 번).
+자리 = 프로세스 메모리 + storage `_derived/m02_layer_index/`(파생 저장본 · 층마다 한 파일 ·
+참조 이름표 기억 `ids.json`) — 없거나 깨지면 다시 지음 · 지워도 됨 · 다른 PC · 옛 코드는 안 읽음.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import threading
+from functools import lru_cache
+from pathlib import Path
+from typing import Any
+
+from common_util.common_util_derived_store import derived_dir, derived_key, file_stamp, read_derived
+from common_util.common_util_json import atomic_write_json
+from config import config_system
+from M02_MasterTemplete import M02_Labor_Ref as refs
+from M02_MasterTemplete import M02_MasterTemplete_Store as store
+from M02_MasterTemplete import M02_Template_Layers as layers
+
+KIND = "m02_layer_index"
+SHAPE = 1 # 기록 꼴이 바뀌면 올림(옛 기록 = 다시 셈)
+IDS = "ids"
+CODE_DIRS = ("M02_MasterTemplete", "M01_MasterData", "resources/master_data/scripts", "common_util")
+_LOCK = threading.RLock()
+_MEM: dict[str, dict[str, Any]] = {} # 층 열쇠 → {항목: 기록}
+_IDS_LOADED: list[bool] = []
+
+
+@lru_cache(maxsize=1)
+def _code() -> str:
+ """셈 코드 내용 지문 — 프로세스에 한 번(코드가 바뀌면 서버가 다시 뜸 · 같은 코드 = 같은 값)."""
+ digest = hashlib.blake2b(digest_size=16)
+ for sub in CODE_DIRS:
+ for path in sorted((config_system.PROJECT_ROOT / sub).glob("*.py")):
+ digest.update(path.name.encode())
+ digest.update(path.read_bytes())
+ return digest.hexdigest()
+
+
+def _stamps(folder: Path, skip: tuple[str, ...] = ()) -> list[tuple[str, str | None]]:
+ """폴더 안 파일 자국 — 첫 단 이름이 `skip` · `_` · `.` 머리인 것은 뺌."""
+ if not folder.is_dir():
+ return []
+ out = []
+ for path in sorted(folder.rglob("*")):
+ rel = path.relative_to(folder).parts
+ if rel[0] in skip or any(p.startswith(("_", ".")) for p in rel) or not path.is_file():
+ continue
+ out.append(("/".join(rel), file_stamp(path)))
+ return out
+
+
+def _ground(folder: Path) -> str:
+ """바탕 지문 — 셈 코드 · M01 폴더 · 이 층 · 지금 층 기본 자리의 곁 파일."""
+ from M01_MasterData import M01_MasterData_Store as m01
+
+ skip = (*layers.KINDS, layers.MANIFEST_NAME)
+ side = [_stamps(f, skip) for f in dict.fromkeys((folder, store.root()))]
+ return derived_key(SHAPE, _code(), str(m01.FOLDER), _stamps(Path(m01.FOLDER)), side)
+
+
+# ── 기록 자리 ─────────────────────────────────────────────────────────
+def _book(folder: Path) -> tuple[str, dict[str, Any]]:
+ key = derived_key(SHAPE, str(folder.resolve()))
+ if key not in _MEM:
+ got = read_derived(layers.storage_root(), KIND, key)
+ value = got.get("value") if got else None
+ _MEM[key] = value if isinstance(value, dict) else {}
+ return key, _MEM[key]
+
+
+def _save(key: str, value: Any) -> None:
+ path = derived_dir(layers.storage_root(), KIND) / f"{key}.json"
+ try:
+ atomic_write_json(path, {"key": key, "value": value})
+ except OSError: # 파생 저장본 — 못 써도 다음에 다시 셈
+ pass
+
+
+# ── 목록 · 판 ─────────────────────────────────────────────────────────
+def list_rows(folder: str | Path) -> list[dict[str, Any]]:
+ """`layers.list_templates` 와 같은 줄 — 판은 자국이 같으면 기억한 것(파일 안 엶)."""
+ folder = Path(folder)
+ with _LOCK:
+ key, recs = _book(folder)
+ dirty: list[str] = []
+
+ def version(path: Path) -> str | None:
+ name = f"{path.parent.name}/{path.stem}"
+ stamp = file_stamp(path) # 읽기 전 자국 — 그새 바뀌면 다음에 다시 셈
+ rec = recs.get(name)
+ if stamp and isinstance(rec, dict) and rec.get("자국") == stamp and "판" in rec:
+ return rec["판"]
+ recs[name] = {"자국": stamp, "판": layers.version_of(path)}
+ dirty.append(name)
+ return recs[name]["판"]
+
+ rows = layers.list_templates(folder, version)
+ have = {f"{row['종류']}/{row['이름']}" for row in rows}
+ gone = [name for name in recs if name not in have]
+ for name in gone:
+ del recs[name]
+ if dirty or gone:
+ _save(key, recs)
+ return rows
+
+
+# ── 서고 요약 ─────────────────────────────────────────────────────────
+def _good(rec: Any, stamp: str | None, ground: str) -> bool:
+ if not (stamp and isinstance(rec, dict) and rec.get("자국") == stamp):
+ return False
+ deps = rec.get("기댐")
+ if rec.get("바탕") != ground or not isinstance(rec.get("요약"), dict):
+ return False
+ return isinstance(deps, dict) and all(file_stamp(Path(p)) == s for p, s in deps.items())
+
+
+def _fresh(folder: Path, kind: str, name: str, rec: Any, stamp: str | None, whole: Any) -> dict:
+ """그 항목만 다시 읽어 셈 — 참조는 읽기 직전 자국을 기댐에 적음."""
+ from M02_MasterTemplete.M02_Labor_Expand import Expand
+
+ path = folder / kind / f"{name}.json"
+ deps: dict[str, str | None] = {}
+
+ def note(p: Path) -> None:
+ deps.setdefault(str(p), file_stamp(p))
+
+ token = refs.SEEN.set(note)
+ try: # 셈마다 새 펼침 — 다른 항목 셈 기억을 나눠 쓰면 기댐이 빠짐
+ out = refs.summary(folder, kind, name, Expand(whole), [])
+ finally:
+ refs.SEEN.reset(token)
+ out.pop("쓰는곳수", None)
+ same = isinstance(rec, dict) and rec.get("자국") == stamp and "판" in rec
+ return {
+ "자국": stamp,
+ "판": rec["판"] if same else layers.version_of(path),
+ "기댐": deps,
+ "요약": out,
+ }
+
+
+def summaries(folder: str | Path, rows: list[dict[str, Any]]) -> None:
+ """`refs.summaries` 와 같은 칸 · 차례 — 기록이 맞으면 파일 안 엶 · 다르면 그 항목만 셈."""
+ folder = Path(folder)
+ lib = [row for row in rows if row["종류"] in layers.LIBRARY_KINDS]
+ if not lib:
+ return
+ with _LOCK:
+ key, recs = _book(folder)
+ ground, whole, dirty = _ground(folder), None, False
+ for row in lib:
+ name = f"{row['종류']}/{row['이름']}"
+ path = folder / row["종류"] / f"{row['이름']}.json"
+ stamp = file_stamp(path)
+ if not _good(recs.get(name), stamp, ground):
+ if whole is None:
+ from M01_MasterData import M01_MasterData_Store as m01
+
+ whole = m01.loaded()[1]
+ rec = _fresh(folder, row["종류"], row["이름"], recs.get(name), stamp, whole)
+ recs[name] = {**rec, "바탕": ground}
+ dirty = True
+ row.update(recs[name]["요약"])
+ if dirty:
+ _save(key, recs)
+ sk = [row for row in lib if row["종류"] == "sangeun" and "고친줄수" in row]
+ if sk:
+ uses, (layer, owner) = _uses(), refs.where(folder)
+ for row in sk:
+ rid = refs.ref_id({"층": layer, "주인": owner, "키": row["이름"]})
+ row["쓰는곳수"] = uses.get(rid, 0)
+
+
+# ── 쓰는 곳 수 ────────────────────────────────────────────────────────
+def _uses() -> dict[str, int]:
+ """참조 이름표 → 담은 파일 수(`refs.users` 개수와 같음) — 쓰는 곳 파일 한 번 훑음."""
+ _load_ids()
+ before = {path: hit[0] for path, hit in refs._IDS.items()}
+ files = [path for _what, _at, path in refs.use_files()]
+ count: dict[str, int] = {}
+ for path in files:
+ got = refs._ids_of(path)
+ for rid in got[1] if got else ():
+ count[rid] = count.get(rid, 0) + 1
+ if any(before.get(path) != refs._IDS[path][0] for path in files if path in refs._IDS):
+ _save_ids()
+ return count
+
+
+def _load_ids() -> None:
+ """참조 이름표 기억을 파생 저장본에서 채움 — 프로세스에 한 번 · 자국은 `_ids_of` 가 다시 봄."""
+ if _IDS_LOADED:
+ return
+ _IDS_LOADED.append(True)
+ got = read_derived(layers.storage_root(), KIND, IDS)
+ for text, one in ((got or {}).get("value") or {}).items():
+ try:
+ mtime, size, name, ids = one
+ refs._IDS.setdefault(Path(text), ((int(mtime), int(size)), (str(name), frozenset(ids))))
+ except (TypeError, ValueError):
+ continue
+
+
+def _save_ids() -> None:
+ """지금 기억을 디스크 것에 덧씀(다른 서버 몫 유지) · 없어진 파일 뺌."""
+ got = read_derived(layers.storage_root(), KIND, IDS)
+ value = dict((got or {}).get("value") or {})
+ for path, ((mtime, size), (name, ids)) in list(refs._IDS.items()):
+ value[str(path)] = [mtime, size, name, sorted(ids)]
+ value = {text: one for text, one in value.items() if Path(text).is_file()}
+ _save(IDS, value)
diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py
index 4c1df9130..120f071cf 100644
--- a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py
+++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py
@@ -27,6 +27,7 @@ from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool, run_with_connection
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_Layer_Access as access
+from M02_MasterTemplete import M02_Layer_Index as index
from M02_MasterTemplete import M02_Library_Flat as flat
from M02_MasterTemplete import M02_MasterTemplete_Items as items_store
from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router
@@ -275,14 +276,14 @@ async def list_layer(
session: dict[str, Any] = Depends(verify_session),
) -> dict[str, Any]:
folder = await _layer_dir(session, layer, project_id=project_id, user_id=user_id, owner=owner)
- rows = await asyncio.to_thread(layers.list_templates, folder)
+ rows = await asyncio.to_thread(index.list_rows, folder) # 판 = 자국 기억(826-1)
manifest = await asyncio.to_thread(layers.read_manifest, folder)
for row in rows:
row["출처"] = manifest.get(f"{row['종류']}/{row['이름']}")
if (row["종류"], row["이름"]) in store.IN_USE: # 화면이 지우기 확인 창 전에 막음
row["쓰는곳"] = store.IN_USE[(row["종류"], row["이름"])]
# 서고(50-3) — 보이는 이름 · 단위 · 기준 금액(51-4)
- await asyncio.to_thread(refs.summaries, folder, rows)
+ await asyncio.to_thread(index.summaries, folder, rows) # 요약 색인(826-1)
return {"층": layer, "양식": rows}
diff --git a/M02_MasterTemplete/M02_Template_Layers.py b/M02_MasterTemplete/M02_Template_Layers.py
index 48c858d5f..9e077af37 100644
--- a/M02_MasterTemplete/M02_Template_Layers.py
+++ b/M02_MasterTemplete/M02_Template_Layers.py
@@ -132,8 +132,10 @@ def _modified(path: Path) -> str:
return utc_from_timestamp(path.stat().st_mtime)
-def list_templates(layer_dir: str | Path) -> list[dict[str, Any]]:
- """층 하나의 양식 목록 `[{종류, 이름, 판, 수정일}]` — 종류 · 이름 차례."""
+def list_templates(layer_dir: str | Path, version: Any = None) -> list[dict[str, Any]]:
+ """층 하나의 양식 목록 `[{종류, 이름, 판, 수정일}]` — 종류 · 이름 차례 ·
+ `version` = 판 셈(없으면 `version_of` · 층 목록은 자국 기억 `M02_Layer_Index`)."""
+ version = version or version_of
base = Path(layer_dir)
rows: list[dict[str, Any]] = []
for kind in KINDS:
@@ -147,7 +149,7 @@ def list_templates(layer_dir: str | Path) -> list[dict[str, Any]]:
{
"종류": kind,
"이름": path.stem,
- "판": version_of(path),
+ "판": version(path),
"수정일": _modified(path),
}
)
diff --git a/common_util/common_util_line_stack_pick.ts b/common_util/common_util_line_stack_pick.ts
index 41252cbb1..f03add116 100644
--- a/common_util/common_util_line_stack_pick.ts
+++ b/common_util/common_util_line_stack_pick.ts
@@ -129,20 +129,68 @@ export function addPicked(doc: FormulaDoc, item: StackItem, kind: MoneyKind, p:
return id;
}
-/** 있는 줄 바꿔 고름 — 그 줄 단가 기호 연결 · 줄 이름 · 규격(단위 · 수량은 그대로 · 같은 로직이면 넘길 입력 그대로) */
+/** 가져온 줄 바꿔 끼움 표지(823-1) — 원래 연결(엑셀 값 기억째) · 줄 이름 · 규격 · 되돌리기용 */
+export interface Swap {
+ 연결: Link;
+ 이름?: string;
+ 규격?: string;
+ /** 단가 기호 이름 */
+ 기호이름?: string;
+}
+type Kept = Link & { 엑셀?: unknown; 바꿈?: Swap };
+
+const sameRes = (a: Link, b: Link): boolean => {
+ const args = (l: Link): string =>
+ JSON.stringify(Object.entries(l.입력 ?? {}).sort(([x], [y]) => (x < y ? -1 : 1)));
+ return a.갈래 === b.갈래 && a.키 === b.키 && args(a) === args(b);
+};
+
+/** 바꾼 가져온 줄 수(823-1 · 「바꾼 줄 n — 원본과 다름」) */
+export const swappedCount = (doc: FormulaDoc): number =>
+ (doc.변수 ?? []).filter((v) => (v.연결 as Kept | undefined)?.바꿈).length;
+
+/** 바꾼 가져온 줄 되돌리기(823-1) — 원래 연결(엑셀 값) · 이름 · 규격 */
+export function unswap(doc: FormulaDoc, i: number, id: string): void {
+ const v = (doc.변수 ?? []).find((x) => varId(x) === id);
+ const line = (doc.금액 ?? [])[i] as { 이름?: string; 규격?: string } | undefined;
+ const sw = (v?.연결 as Kept | undefined)?.바꿈;
+ if (!v || !line || !sw) return;
+ v.연결 = sw.연결;
+ if (sw.기호이름 !== undefined) v.이름 = sw.기호이름;
+ else delete v.이름;
+ if (sw.이름 !== undefined) line.이름 = sw.이름;
+ else delete line.이름;
+ if (sw.규격) line.규격 = sw.규격;
+ else delete line.규격;
+}
+
+/** 있는 줄 바꿔 고름 — 그 줄 단가 기호 연결 · 줄 이름 · 규격(단위 · 수량은 그대로 · 같은 로직이면 넘길 입력 그대로) ·
+ * 가져온 이은 줄(엑셀 값 기억)을 다른 것으로 = 그 줄만 우리 값(엑셀 값 뗌 · `바꿈` = 원래 · 823-1) · 원래 것 다시 고름 = 되돌림 */
export function repick(doc: FormulaDoc, i: number, id: string, kind: MoneyKind, p: Picked): void {
const v = (doc.변수 ?? []).find((x) => varId(x) === id);
const line = (doc.금액 ?? [])[i] as { 이름?: string; 규격?: string } | undefined;
if (!v || !line) return;
- const was = v.연결 ?? {};
- const now = linkOf(kind, p);
+ const was = (v.연결 ?? {}) as Kept;
+ const now = linkOf(kind, p) as Kept;
if (now.갈래 === "요소" && was.지역) now.지역 = was.지역; // 지역 옵션 이음 그대로
if (now.갈래 === "단가산출" && was.키 === now.키) now.입력 = { ...was.입력, ...now.입력 }; // 같은 로직 = 다른 넘길 입력 그대로
- // 가져온 기계 줄(812-9) — 엑셀 값 기억 그대로(엑셀 단가 기준 금액 안 바뀜 · 못 이은 기계 = 숫자 값 · 묶음)
- const kept = (was as { 엑셀?: unknown }).엑셀 ?? (was.갈래 === "숫자" ? was.값 : undefined);
- if (kind === "기계" && kept !== undefined) (now as { 엑셀?: unknown }).엑셀 = kept;
+ const orig = (was.바꿈?.연결 ?? was) as Kept;
+ if (was.바꿈 && sameRes(orig, now)) return unswap(doc, i, id); // 원래 것 다시 고름
const cost = (line as { 비목?: string }).비목 ?? "";
if (kind === "기계" && ["노무비", "재료비", "경비"].includes(cost)) now.칸 = cost; // 비목 한 칸 줄 = 그 칸
+ if ("엑셀" in orig && orig.갈래 !== "숫자") {
+ if (sameRes(orig, now)) return; // 같은 것 = 그대로
+ now.바꿈 = was.바꿈 ?? {
+ 연결: was,
+ ...(line.이름 !== undefined ? { 이름: line.이름 } : {}),
+ ...(line.규격 ? { 규격: line.규격 } : {}),
+ ...(v.이름 !== undefined ? { 기호이름: v.이름 } : {}),
+ };
+ } else {
+ // 못 이은 가져온 기계 줄(812-9) 고름 = 이음 — 엑셀 값 기억(엑셀 단가 기준 금액 안 바뀜 · 숫자 값 · 묶음)
+ const kept = was.엑셀 ?? (was.갈래 === "숫자" ? was.값 : undefined);
+ if (kind === "기계" && kept !== undefined) now.엑셀 = kept;
+ }
v.연결 = now;
v.이름 = p.이름;
line.이름 = p.이름;
diff --git a/docs/raw/요구_1104/_조사_804_저장소속도.md b/docs/raw/요구_1104/_조사_804_저장소속도.md
index 1a7102248..66dfcce43 100644
--- a/docs/raw/요구_1104/_조사_804_저장소속도.md
+++ b/docs/raw/요구_1104/_조사_804_저장소속도.md
@@ -54,3 +54,10 @@
- 사본에 든 값 = 177 줄: 기계 138(두 계산식의 「기계」 고르기 후보 전부 — GC000267 9 대 + GC000269 129 대 · 14.6 만 자) · 유가 36(휘발유 · 경유 × 지역 18) · 직종 3 → 173 줄(약 98 %)은 이 단가산출이 안 쓰는 값.
- 까닭 = 사본 모으기가 「식이 고를 수 있는 후보 전부」 를 닫힌 묶음으로 박음(옵션을 바꿔도 사본 안에서 셈하려는 뜻).
- 안 C ② 가 이것 — 실제로 읽은 줄만 박고 · 옵션 · 기계를 바꿔 새로 필요한 줄은 바꾸는 때의 M01 값으로 가져와 저장 때 박음(822-1 이 넣은 「읽은 줄을 저장 때 박기」 길 재사용).
+
+## 6. 사용자 결정(요구 808) · 진행 차례
+
+- 기계 = 항목 하나가 파일 하나(M01 기계 · 내 기계) · 자재는 한 파일 그대로 · A · B · C 진행.
+- C 는 ②(실제로 읽은 줄만)부터 — 177 줄 → 4 줄이면 ①(두 번 든 것) · ③(공유 저장)의 남는 효과가 작고 · ② 는 옛 코드가 읽어도 무해(없는 줄은 지금 M01 값으로 읽음) · ① · ③ 은 파일 꼴이 바뀌어 **데스크탑(임시 배포 · 옛 코드)이 같은 storage 를 읽는 동안**은 깨질 수 있어 전역 깃 싱크와 묶을 때만.
+- 바꿔 끼운 기계 · 옵션의 값 = 바꾸는 때의 M01 값(정함 · 브레인).
+- 걸음: 826-1 층 목록 요약(sub2 · 바로) → 826-2 M01 기계 항목별 파일 · 내 기계 항목별 파일 · M01 저장 길 줄이기(sub1 · 822-2b 뒤) → 826-3 사본 = 읽은 줄만 · SK 저장 길 줄이기(sub3 · 823-1 뒤).
diff --git a/docs/raw/요구_1104/사용자요구_1104.md b/docs/raw/요구_1104/사용자요구_1104.md
index e4db2e3e0..4ae390500 100644
--- a/docs/raw/요구_1104/사용자요구_1104.md
+++ b/docs/raw/요구_1104/사용자요구_1104.md
@@ -1070,3 +1070,9 @@ M02에서 내 기계와 내 품목은 개인관리 컨테이너로 병합함.
## 요구 807 (11-12 · 사용자 · A · B · C 는 잠시 대기 · 물음 먼저 — 단가산출의 항목 · 기계의 항목이 하나의 파일인지)
ABC는 잠시대기, 내 질문에 대한 대답부터 단가산출의 항목, 기계의 항목이 하나의 파일이야?
+
+## 요구 808 (11-12 · 사용자 · 1 기계 하나의 항목은 하나의 파일로 분리하는 게 맞아 보임 · 2 자재는 어쩔 수 없음 · 3 A · B · C 진행)
+
+1. 기계 하나의 항목은 하나의 파일로 분리하는게 맞아보임.
+2. 자재는 어절수 없음.
+3. ABC 진행
diff --git a/resources/tester/helper_823_1_swap.cjs b/resources/tester/helper_823_1_swap.cjs
new file mode 100644
index 000000000..9b7b9efc9
--- /dev/null
+++ b/resources/tester/helper_823_1_swap.cjs
@@ -0,0 +1,34 @@
+/* 823-1 헬퍼 — common_util_line_stack_pick.ts(repick · unswap · swappedCount) · 가져온 식 보기(importView)를
+ * 그 자리에서 트랜스파일해 Node 로 돌림. stdin = {문서, 걸음 [{고름: {i, id, kind, p}} | {되돌림: {i, id}}]}
+ * stdout = 걸음마다 {문서, 엑셀보기, 바꾼줄} */
+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 util = (name) => load(path.join(__dirname, "..", "..", "common_util", `${name}.ts`));
+const pick = util("common_util_line_stack_pick");
+const imp = util("common_util_formula_doc_import");
+
+const input = JSON.parse(fs.readFileSync(0, "utf8"));
+const doc = input.문서;
+const out = [];
+for (const step of input.걸음) {
+ if (step.고름) pick.repick(doc, step.고름.i, step.고름.id, step.고름.kind, step.고름.p);
+ if (step.되돌림) pick.unswap(doc, step.되돌림.i, step.되돌림.id);
+ const now = JSON.parse(JSON.stringify(doc));
+ out.push({ 문서: now, 엑셀보기: imp.importView(now, "엑셀"), 바꾼줄: pick.swappedCount(now) });
+}
+process.stdout.write(JSON.stringify(out));
diff --git a/resources/tester/helper_est_native.cjs b/resources/tester/helper_est_native.cjs
index 9b5f65fc9..6208af505 100644
--- a/resources/tester/helper_est_native.cjs
+++ b/resources/tester/helper_est_native.cjs
@@ -9,6 +9,7 @@ for (const file of [
"native-machine.js",
"native-rows.js",
"native-text.js",
+ "native-quantity-wpt.cjs",
"native-quantity.cjs",
"native-wonga.js",
"native-engine.js",
@@ -33,8 +34,11 @@ for (const file of [
"native-verify.js",
"native-block-actions.js",
"native-extra.js",
+ "native-applied-cost.js",
+ "native-progress-view.js",
"native-business-actions.js",
"native-forms.js",
+ "native-excel-to-est.js",
]) {
vm.runInContext(fs.readFileSync(path.join(base, file), "utf8"), context, { filename: file });
}
diff --git a/resources/tester/test_823_1_imported_swap.py b/resources/tester/test_823_1_imported_swap.py
new file mode 100644
index 000000000..3704b9561
--- /dev/null
+++ b/resources/tester/test_823_1_imported_swap.py
@@ -0,0 +1,189 @@
+# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀
+"""823-1 불러온 단가산출 바꿔 끼움(요구 800-1 · 801-1) — 임시 storage.
+
+엑셀 단가 기준에서 이은 기계 줄을 다른 기계로 = 그 줄만 우리 값(엑셀 값 뗌 · `바꿈` = 원래) ·
+다른 줄 엑셀 값 그대로 · 되돌리기 · 원래 것 다시 고름 = 되돌림 · 내 기계로 ·
+저장 뒤 로직 사본에 박힘 · 「바꾼 줄 n」 · 인력 바꿈 같은 길 ·
+못 이은 기계 고름(812-9) = 엑셀 값 그대로 · 옛 칸 지문 24554.0 = 24554.
+"""
+
+from __future__ import annotations
+
+import copy
+import json
+import os
+import subprocess
+
+from M02_MasterTemplete import M02_Doc_Copy as dc
+from M02_MasterTemplete import M02_Doc_Import as dim
+from M02_MasterTemplete import M02_Doc_Sk as dsk
+from M02_MasterTemplete import M02_Doc_Sk_Path as path
+from M02_MasterTemplete import M02_MasterTemplete_Store as store
+from M02_MasterTemplete import M02_MyMachine as mym
+from M02_MasterTemplete import M02_Sangeun_Imported as imp
+from resources.tester.test_822_1_copy_gap import _move
+from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층
+
+HERE = os.path.dirname(__file__)
+DIG = "GC000267" # 굴착기 시간당 경비
+NOW, NEW, NEW_EQ = "'0201-0070'", "'0201-0100'", "EQ000022" # 0.7㎥ → 1.0㎥
+XL = {"노무비": 59020, "재료비": 19473, "경비": 24554}
+MAN, MAN2 = "LB000002", "LB000001" # 보통인부 → 다른 직종
+
+
+def _at(folder, fn, *args):
+ with store.at(folder):
+ return fn(*args)
+
+
+def _ts(doc: dict, steps: list[dict]) -> list[dict]:
+ proc = subprocess.run(
+ ["node", os.path.join(HERE, "helper_823_1_swap.cjs")],
+ input=json.dumps(
+ dc.ml._norm({"문서": doc, "걸음": steps}), ensure_ascii=False, default=str
+ ),
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ )
+ assert proc.returncode == 0, proc.stderr[-3000:]
+ return json.loads(proc.stdout)
+
+
+def _made(folder) -> tuple[str, dict]:
+ """가져온 식 SK — 기계 줄(GC000267 0.7㎥ · 엑셀 묶음) + 인력 줄(보통인부 · 엑셀 단가)."""
+ mach = {
+ "종류": "로직", "이름": "굴착기(무한궤도)", "규격": "0.7㎥", "단위": "hr", "수량": "0.1",
+ "값묶음": XL, "이음": f"로직({DIG}, 기계={NOW}, 지역='전국평균', 보정작업='해당 없음')",
+ } # fmt: skip
+ man = {"종류": "인력", "이름": "보통인부", "단위": "인", "수량": "0.5", "비목": "노무비",
+ "단가": 165545, "이음": MAN} # fmt: skip
+ row = {"이름": "sub3임시 바꿈", "결과단위": "원/㎥", "호표": [mach, man]}
+ key = _at(folder, lambda: imp.create(row, None, "sub3임시 바꿈")["이름"])
+ return key, _at(folder, path.read, key)
+
+
+def _vars(doc: dict) -> dict:
+ return {dc.md.var_id(v): v for v in doc["변수"]}
+
+
+def _sym(doc: dict, kind: str) -> tuple[int, str]:
+ """(금액 줄 차례, 단가 기호) — 기계 = 단가산출 연결 · 인력 = 요소 연결."""
+ by = _vars(doc)
+ for i, line in enumerate(doc["금액"]):
+ var = by.get(str(line.get("단가")))
+ if var and (var["연결"].get("갈래") == ("단가산출" if kind == "기계" else "요소")):
+ return i, dc.md.var_id(var)
+ raise AssertionError(kind)
+
+
+def _lines(ans: dict) -> list:
+ return [None if x.get("금액") is None else float(x["금액"]) for x in ans.get("줄") or []]
+
+
+def _pick(i, sym, kind, key, name, spec="", **given):
+ p = {"키": key, "이름": name, **({"규격": spec} if spec else {})}
+ p |= {"입력": given} if given else {}
+ return {"고름": {"i": i, "id": sym, "kind": kind, "p": p}}
+
+
+def test_swap_machine_excel_basis(world, monkeypatch):
+ """기계 0.7 → 1.0㎥ = 그 줄 우리 값 · 인력 줄 엑셀 그대로 · TS 보기 = 서버 보기 ·
+ 되돌리기 · 원래 것 다시 고름."""
+ me = world["personal"]
+ key, got = _made(me)
+ doc, was = got["문서"], got["셈"]
+ i, sym = _sym(doc, "기계")
+ j, man = _sym(doc, "인력")
+ steps = _ts(
+ doc,
+ [
+ _pick(i, sym, "기계", DIG, "굴착기(무한궤도)", "1.0㎥", 기계=NEW),
+ {"되돌림": {"i": i, "id": sym}},
+ _pick(i, sym, "기계", DIG, "굴착기(무한궤도)", "1.0㎥", 기계=NEW),
+ _pick(i, sym, "기계", DIG, "굴착기(무한궤도)", "0.7㎥", 기계=NOW),
+ ],
+ )
+ swapped = steps[0]["문서"]
+ link = _vars(swapped)[sym]["연결"]
+ assert steps[0]["바꾼줄"] == 1 and "엑셀" not in link and link["입력"]["기계"] == NEW
+ assert link["바꿈"]["연결"]["엑셀"] == XL and link["바꿈"]["규격"] == "0.7㎥"
+ # TS 엑셀 보기 = 서버 보기(짝) — 바꾼 줄 = 연결 · 인력 = 숫자
+ assert dc.ml._norm(steps[0]["엑셀보기"]) == dc.ml._norm(dim.view(swapped, "엑셀"))
+ assert _vars(steps[0]["엑셀보기"])[man]["연결"]["갈래"] == "숫자"
+ # 서버 미리 셈 — 기계 줄 = 우리 값(1.0㎥) · 인력 줄 엑셀 그대로
+ look = _at(me, path.preview, key, {"문서": swapped})["셈"]
+ sk = _at(me, lambda: store.read("sangeun", key)["문서"])
+ ours = dim.run(swapped, {**sk, "가져온": {**sk["가져온"], "단가기준": "우리"}})
+ assert _lines(look)[i] == _lines(ours)[i] != _lines(was)[i]
+ assert _lines(look)[j] == _lines(was)[j]
+ assert look["계"] != was["계"]
+ # 되돌리기 · 원래 것 다시 고름 = 처음 문서 그대로
+ for at in (1, 3):
+ assert steps[at]["바꾼줄"] == 0
+ assert dc.ml._norm(steps[at]["문서"]) == dc.ml._norm(doc)
+ # 저장 → 다시 읽음 그대로 · 바꾼 기계 줄이 로직 사본에 · M01 가격 바뀌어도 그대로
+ 판 = _at(me, path.read, key)["판"]
+ _at(me, path.save, key, {"문서": swapped, "판": 판})
+ again = _at(me, path.read, key)
+ assert again["셈"]["계"] == look["계"] and dim.swapped(again["문서"]) == 1
+ kept = _at(me, lambda: store.read("sangeun", key)["문서"]["사본"])
+ assert NEW_EQ in {r["키"] for r in [*kept["로직"], *kept["단가"]]}
+ _move(monkeypatch, NEW_EQ)
+ assert _at(me, path.read, key)["셈"]["계"] == look["계"]
+
+
+def test_swap_to_my_machine(world):
+ """내 기계로 바꿔 끼움 = 그 내 기계 시간당 값 · 저장 뒤 사본에."""
+ me = world["personal"]
+ made = _at(me, mym.create, {"이름": "sub3임시기계", "규격": "1㎾", "방식": "바로",
+ "값": {"가격": 1000, "손료계수": 2000}}) # fmt: skip
+ row = made["줄"]
+ key, got = _made(me)
+ doc = got["문서"]
+ i, sym = _sym(doc, "기계")
+ arg = f"'{row['원문번호']}'"
+ steps = _ts(doc, [_pick(i, sym, "기계", mym.logic_key(row), row["이름"], 기계=arg)])
+ swapped = steps[0]["문서"]
+ assert steps[0]["바꾼줄"] == 1
+ look = _at(me, path.preview, key, {"문서": swapped})["셈"]
+ hour = made["시간당"]["계"]
+ assert abs(_lines(look)[i] - hour * 0.1) < 1 # 수량 0.1 × 시간당
+ 판 = _at(me, path.read, key)["판"]
+ _at(me, path.save, key, {"문서": swapped, "판": 판})
+ kept = _at(me, lambda: store.read("sangeun", key)["문서"]["사본"])
+ assert row["키"] in {r["키"] for r in [*kept["로직"], *kept["단가"]]}
+
+
+def test_swap_labor_same_way(world):
+ """인력 직종 바꿔 끼움 = 같은 길(바꿈 · 우리 값 · 되돌리기)."""
+ me = world["personal"]
+ key, got = _made(me)
+ doc, was = got["문서"], got["셈"]
+ j, man = _sym(doc, "인력")
+ steps = _ts(doc, [_pick(j, man, "인력", MAN2, "다른 직종"), {"되돌림": {"i": j, "id": man}}])
+ link = _vars(steps[0]["문서"])[man]["연결"]
+ assert steps[0]["바꾼줄"] == 1 and "엑셀" not in link and link["바꿈"]["연결"]["키"] == MAN
+ look = _at(me, path.preview, key, {"문서": steps[0]["문서"]})["셈"]
+ assert _lines(look)[j] != _lines(was)[j]
+ assert dc.ml._norm(steps[1]["문서"]) == dc.ml._norm(doc)
+
+
+def test_unlinked_machine_pick_keeps_excel(world):
+ """못 이은 가져온 기계 줄(숫자 연결) 고름 = 이음(812-9) — 엑셀 값 그대로 · 바꿈 아님."""
+ me = world["personal"]
+ _, got = _made(me)
+ doc = copy.deepcopy(got["문서"])
+ i, sym = _sym(doc, "기계")
+ _vars(doc)[sym]["연결"] = {"갈래": "숫자", "값": XL}
+ steps = _ts(doc, [_pick(i, sym, "기계", DIG, "굴착기(무한궤도)", "0.7㎥", 기계=NOW)])
+ link = _vars(steps[0]["문서"])[sym]["연결"]
+ assert steps[0]["바꾼줄"] == 0 and link["엑셀"] == XL and "바꿈" not in link
+
+
+def test_old_fp_float_roundtrip():
+ """파일 24554.0 · 화면 왕복 24554 = 같은 옛 칸 지문(페이지 [저장] 409 막음)."""
+ one = {"가져온": {"로직": {"호표": [{"자원": {"단가": {"S": 24554.0, "q": 0.5}}}]}}}
+ two = {"가져온": {"로직": {"호표": [{"자원": {"단가": {"S": 24554, "q": 0.5}}}]}}}
+ one["가져온"]["단가기준"] = two["가져온"]["단가기준"] = "엑셀"
+ assert dsk.old_fp(one) == dsk.old_fp(two)
diff --git a/resources/tester/test_826_1_layer_index.py b/resources/tester/test_826_1_layer_index.py
new file mode 100644
index 000000000..f2298a3ab
--- /dev/null
+++ b/resources/tester/test_826_1_layer_index.py
@@ -0,0 +1,208 @@
+"""826-1 층 목록 요약 색인 — 답 = 다시 셈한 것 · 자국 같으면 항목 파일 안 엶 · 고침 · 지움 · 더함 ·
+참조 고침 · M01 값 바뀜 · 색인 깨짐 뒤에도 맞음 · 판 충돌(409) 그대로."""
+
+from __future__ import annotations
+
+import json
+import shutil
+from pathlib import Path
+
+import pytest
+
+from M01_MasterData import M01_MasterData_Store as m01
+from M02_MasterTemplete import M02_Labor_Ref as refs
+from M02_MasterTemplete import M02_Layer_Index as index
+from M02_MasterTemplete import M02_MasterTemplete_Store as store
+from M02_MasterTemplete import M02_Template_Layers as layers
+from resources.tester.test_m02_labor_ref import COMPANY, world # noqa: F401 — 같은 임시 층
+
+PCT = "GC001109" # 원/㎡ · 인력 둘 + 덧줄 %
+WAGE = "LB000001"
+LIB = ("sangeun", "precombo", "labor")
+
+
+@pytest.fixture()
+def fresh(world, monkeypatch): # noqa: F811
+ """색인 기억 비움 — 시험끼리 안 섞임."""
+ monkeypatch.setattr(index, "_MEM", {})
+ monkeypatch.setattr(index, "_IDS_LOADED", [])
+ monkeypatch.setattr(refs, "_IDS", {})
+ return world
+
+
+def _forget():
+ index._MEM.clear()
+ index._IDS_LOADED.clear()
+ refs._IDS.clear()
+
+
+def _sk(folder, name, extra=None):
+ doc = {"이름": name, "단위": "㎡", "바탕": {"로직": PCT}, "더한줄": extra or []}
+ with store.at(folder):
+ return refs.create("sangeun", doc)["이름"]
+
+
+def _wage_line(qty=1):
+ return {"종류": "인력", "요소": WAGE, "이름": "보통인부", "단위": "인", "수량": qty}
+
+
+def _old(folder):
+ rows = layers.list_templates(folder)
+ refs.summaries(folder, rows)
+ return rows
+
+
+def _new(folder):
+ rows = index.list_rows(folder)
+ index.summaries(folder, rows)
+ return rows
+
+
+def _same(folder):
+ got = _new(folder)
+ assert json.dumps(got, ensure_ascii=False) == json.dumps(_old(folder), ensure_ascii=False)
+ return got
+
+
+@pytest.fixture()
+def reads(monkeypatch):
+ """항목 파일(서고 · 일위대가) 읽기 수."""
+ seen: list[str] = []
+ real = Path.read_bytes
+
+ def counting(self):
+ if self.parent.name in LIB:
+ seen.append(f"{self.parent.name}/{self.stem}")
+ return real(self)
+
+ monkeypatch.setattr(Path, "read_bytes", counting)
+ return seen
+
+
+def _shelf(w):
+ up = _sk(w["company"], "회사 단가산출")
+ mine = w["personal"]
+ a = _sk(mine, "가", [_wage_line()])
+ b = _sk(mine, "나", [{"참조": {**COMPANY, "키": up}, "수량": 2}])
+ with store.at(mine):
+ sc = refs.create(
+ "precombo",
+ {
+ "이름": "일위대가",
+ "단위": "㎡",
+ "담은로직": [{"참조": {**COMPANY, "키": up}, "수량": {"손": 3}}],
+ },
+ )["이름"]
+ return up, a, b, sc
+
+
+def test_same_and_no_reads_when_stamps_same(fresh, reads):
+ up, a, b, sc = _shelf(fresh)
+ mine = fresh["personal"]
+ first = _same(mine)
+ assert {r["이름"] for r in first} >= {a, b, sc} and first[0]["기준금액"] is not None
+ reads.clear()
+ assert _new(mine) == first and reads == [] # 메모리 기억
+ _forget()
+ reads.clear()
+ assert _new(mine) == first and reads == [] # 디스크 파생 저장본(서버 다시 뜸)
+ # 쓰는곳수 — 회사 SK 를 개인 SK 「나」 가 씀(사전조합은 사본으로 펼쳐 담음 · 740-1)
+ company = _same(fresh["company"])
+ assert company[0]["쓰는곳수"] == 1
+
+
+def _unfreeze(folder, key):
+ """옛 꼴(사본 뗌) — M01 을 지금 값으로 셈(사본 있으면 만든 때 값 그대로)."""
+ doc = json.loads(layers.template_path(folder, "sangeun", key).read_bytes())
+ old = {k: v for k, v in doc.items() if k not in ("사본", "수식", "M01판", "바탕판")}
+ layers.write_template(folder, "sangeun", key, old)
+
+
+def test_edit_delete_add_and_dep_change(fresh, reads):
+ up, a, b, sc = _shelf(fresh)
+ mine = fresh["personal"]
+ gone = _sk(mine, "지울것")
+ _same(mine)
+ with store.at(mine): # 고침 · 지움
+ got = store.read("sangeun", a)
+ store.write("sangeun", a, got["판"], {**got["문서"], "더한줄": [_wage_line(3)]})
+ store.delete("sangeun", gone)
+ c = _sk(mine, "다") # 더함
+ reads.clear()
+ rows = _new(mine)
+ assert set(reads) == {f"sangeun/{a}", f"sangeun/{c}"} # 바뀐 항목만 다시 읽음
+ assert rows == _old(mine)
+ titles = {r["이름"]: r.get("제목") for r in rows if r["종류"] in index.layers.LIBRARY_KINDS}
+ assert gone == c and titles == {
+ a: "가",
+ b: "나",
+ c: "다",
+ sc: "일위대가",
+ } # 지운 키를 새 것이 받음
+ reads.clear()
+ was = {r["이름"]: r.get("기준금액") for r in _new(mine)}
+ assert reads == []
+ with store.at(fresh["company"]): # 사전조합이 담은 위 층 SK 고침 → 그 사전조합만 다시
+ got = store.read("sangeun", up)
+ store.write("sangeun", up, got["판"], {**got["문서"], "더한줄": [_wage_line(5)]})
+ reads.clear()
+ now = {r["이름"]: r.get("기준금액") for r in _new(mine)}
+ assert set(reads) == {f"precombo/{sc}", f"sangeun/{up}"}
+ assert now[sc] != was[sc] and {k: v for k, v in now.items() if k != sc} == {
+ k: v for k, v in was.items() if k != sc
+ }
+ assert _new(mine) == _old(mine)
+
+
+def test_m01_change_recounts_amount(fresh, tmp_path, monkeypatch):
+ copy = tmp_path / "m01"
+ copy.mkdir()
+ for path in Path(m01.FOLDER).glob("*.json"):
+ shutil.copyfile(path, copy / path.name)
+ monkeypatch.setattr(m01, "FOLDER", copy)
+ mine = fresh["personal"]
+ a = _sk(mine, "가", [_wage_line()])
+ _unfreeze(mine, a)
+ one = next(r for r in _same(mine) if r["이름"] == a)["기준금액"]
+ assert one is not None
+ wages = json.loads((copy / "인력.json").read_text(encoding="utf-8"))
+ for row in wages["줄"]:
+ if row.get("키") == WAGE:
+ row["값"] = float(row["값"]) * 2
+ (copy / "인력.json").write_text(json.dumps(wages, ensure_ascii=False), encoding="utf-8")
+ two = next(r for r in _same(mine) if r["이름"] == a)["기준금액"]
+ assert two != one
+
+
+def test_broken_index_rebuilds(fresh):
+ _up, _a, _b, _sc = _shelf(fresh)
+ mine = fresh["personal"]
+ first = _same(mine)
+ folder = index.derived_dir(layers.storage_root(), index.KIND)
+ for path in folder.glob("*.json"):
+ path.write_text("{깨짐", encoding="utf-8")
+ _forget()
+ assert _same(mine) == first
+ for path in folder.glob("*.json"):
+ path.write_text(json.dumps({"key": path.stem, "value": {"sangeun/x": 1}}), encoding="utf-8")
+ _forget()
+ assert _same(mine) == first
+ shutil.rmtree(folder)
+ _forget()
+ assert _same(mine) == first
+
+
+def test_version_conflict_still_409(fresh):
+ mine = fresh["personal"]
+ a = _sk(mine, "가")
+ listed = next(r for r in index.list_rows(mine) if r["이름"] == a)["판"]
+ path = layers.template_path(mine, "sangeun", a)
+ assert listed == layers.version_of(path)
+ doc = json.loads(path.read_bytes())
+ layers.write_template(
+ mine, "sangeun", a, {**doc, "단위": "m"}, version=listed, check_version=True
+ )
+ with pytest.raises(layers.StaleTemplate): # 옛 판으로 또 저장
+ layers.write_template(mine, "sangeun", a, doc, version=listed, check_version=True)
+ fresh_ver = next(r for r in index.list_rows(mine) if r["이름"] == a)["판"]
+ assert fresh_ver == layers.version_of(path) != listed
diff --git a/resources/tester/test_est_accom_option.py b/resources/tester/test_est_accom_option.py
new file mode 100644
index 000000000..10c919faa
--- /dev/null
+++ b/resources/tester/test_est_accom_option.py
@@ -0,0 +1,86 @@
+"""기성 환경설정: 옵션 기본값·비목 열 순서·표 머리 반영."""
+
+from test_est_rea_parity import run
+
+
+def test_defaults_and_part_order():
+ result = run(
+ "math",
+ """(()=>{
+ const d=EstProgressView.get({});
+ const o=(x)=>EstProgressView.parts(EstProgressView.get({Display:{order:x.order},AccomOption:x}));
+ return {code:d.code,sajung:d.sajung,gubun:d.gubun,
+ a:o({order:0}),b:o({order:2,siljuk:true}),c:o({order:3}),d:o({order:5})};
+ })()""",
+ )
+ assert result == {
+ "code": True,
+ "sajung": True,
+ "gubun": False,
+ "a": ["T", "N", "G", "J"],
+ "b": ["N", "G", "J", "S", "T"],
+ "c": ["G", "N", "J", "T"],
+ "d": ["T", "N", "G", "J"],
+ }
+
+
+def test_view_headers_follow_options_and_zero_hiding():
+ result = run(
+ "math",
+ """(()=>{
+ const p=EstNative.empty();p.options.SourceRefresh=false;
+ p.tables.EstMainDat=[{Code:'A',Name:'a',GN:10,GU:100},{LinkIndex:54}];
+ const q=EstNative.calculate(p);q.progress=[{id:'r1',name:'1회',inputs:{},locked:false}];
+ const g=globalThis;g.EstApp={...(g.EstApp||{}),esc:String,number:String};
+ g.EstProgressWonga={render:()=>''};
+ const box=(t,tools,body)=>body,button=()=>'',table=(h,rows)=>JSON.stringify({h,rows});
+ const st={progressPage:0,selection:new Set()};
+ const head=(o)=>{q.options.Display=o.d;q.options.AccomOption=o.a;
+ return JSON.parse(EstProgressView.view({...st},q,{box,button,table}).split('
{
+ const rows=[{Index:2,Index_:1,Name:'b',Name_:'a',Size:'s',Size_:' ',GN:5,GN_:3,GU:10,GU_:8,GC:50,GC_:24},
+ {Name:'c',GN:1,GU:4}];
+ const o=EstProgress.originRows(rows);
+ const p=EstNative.empty();p.options.SourceRefresh=false;
+ p.tables.EstMainDat=[{Code:'A',Name:'a',GN:10,GN_:4,GU:100,GU_:100},{LinkIndex:54}];
+ const q=EstNative.calculate(p);
+ q.progress=[{id:'r1',name:'1회',inputs:{},locked:false,loadMethod:'origin'},
+ {id:'r2',name:'2회',inputs:{},locked:false}];
+ const a=EstProgress.calculate(q,'r1').rows[0].GN;
+ q.progress=[q.progress[1]];
+ const b=EstProgress.calculate(q,'r2').rows[0].GN;
+ return {o:o.map(r=>[r.Index,r.Name,r.Size,r.GN,r.GU,r.GC]),a,b};
+ })()""",
+ )
+ assert result == {
+ "o": [[1, "a", "s", 3, 8, 24], [None, "c", None, 1, 4, None]],
+ "a": 4,
+ "b": 10,
+ }
diff --git a/resources/tester/test_est_applied_cost.py b/resources/tester/test_est_applied_cost.py
new file mode 100644
index 000000000..9e26b5e68
--- /dev/null
+++ b/resources/tester/test_est_applied_cost.py
@@ -0,0 +1,86 @@
+"""적용대가 분류 내역서: 수량불러오기(위치 짝)·적용대가집계표·보기 변경."""
+
+from test_est_rea_parity import run
+
+
+def test_gn_load_copies_quantity_by_position_and_wonga_cost():
+ result = run(
+ "math",
+ """(()=>{
+ const p=EstNative.empty();p.options.SourceRefresh=false;
+ p.tables.EstMainDat=[{Code:'A',GN:10,GU:80},{Code:'B',GN:20,GU:50},{Code:'C',GN:5,GU:10}];
+ p.tables.Wonga=[{Name:'재료비',Cost:0},{Name:'노무비',Cost:0}];
+ const src={tables:{EstMainDat:[{GN:3},{GN:7}],Wonga:[{Cost:111},{Cost:222},{Cost:333}]}};
+ const n=EstAppliedCost.load(p,3,src);
+ const q=EstBusiness.baseline(EstNative.calculate(structuredClone(p)));
+ EstAppliedCost.load(q,0,{tables:{EstMainDat:[{GN:9}],Wonga:[{Cost:500}]}});
+ const r=structuredClone(p);delete r.baseline;
+ EstAppliedCost.load(r,0,{tables:{EstMainDat:[{GN:4}]}});
+ return {n,actual:p.tables.EstMainDat.map(x=>x.EscActual??null),
+ work:p.tables.Wonga.map(x=>x.EsWorkCost),base:q.baseline.tables.EstMainDat[0].GN,
+ contract:q.tables.Wonga[0].EsContractCost,old:r.tables.EstMainDat[0].GN_};
+ })()""",
+ )
+ assert result == {
+ "n": 2,
+ "actual": [3, 7, None],
+ "work": [111, 222],
+ "base": 9,
+ "contract": 500,
+ "old": 4,
+ }
+
+
+def test_summary_uses_loaded_contract_cost_or_contract_calculation():
+ result = run(
+ "math",
+ """(()=>{
+ const p=EstNative.empty();p.options.SourceRefresh=false;p.options.EscDesignTotal=1000;
+ p.tables.EstMainDat=[{Code:'A',Name:'a',GN:10,GU:100},{LinkIndex:54}];
+ p.tables.Wonga=[{Name:'재료비',VSusic:'',BaseField:'GC',LinkIndex:1}];
+ const q=EstBusiness.baseline(EstNative.calculate(p));
+ q.tables.EstMainDat[0].EscActual=4;
+ const a=EstAppliedCost.summary(q).map(x=>[x.contract,x.except,x.apply]);
+ q.tables.Wonga[0].EsContractCost=2000;
+ const b=EstAppliedCost.summary(q).map(x=>[x.contract,x.except,x.apply]);
+ return {a,b};
+ })()""",
+ )
+ contract, except_, apply = result["a"][0]
+ assert apply == contract - except_
+ assert result["b"][0][0] == 2000
+ assert result["b"][0][2] == 2000 - result["b"][0][1]
+
+
+def test_style_toggle_shows_quantity_band_only():
+ result = run(
+ "math",
+ """(()=>{
+ const p=EstNative.empty();p.options.SourceRefresh=false;
+ p.tables.EstMainDat=[{Code:'A',Name:'a',GN:10,GU:100,EscPS:2}];
+ const q=EstBusiness.baseline(EstNative.calculate(p));
+ const state={project:q,view:'escalation',escalationPage:0,selection:new Set()};
+ const box=(t,tools,body)=>tools+body,button=(a,t)=>``,
+ table=(h,rows)=>JSON.stringify({h,rows});
+ const g=globalThis;g.EstApp={...(g.EstApp||{}),esc:String,number:String,render(){}};g.EstViews={cell:(t,i,k,v)=>String(v)};
+ const plain=EstAppliedCost.view(state,q,{box,button,table});
+ EstAppliedCost.handle('applied-style-toggle',state);
+ const band=EstAppliedCost.view(state,q,{box,button,table});
+ const head=(s)=>JSON.parse(s.slice(s.indexOf('{"h"'))).h;
+ return {plain:head(plain).length,band:head(band)};
+ })()""",
+ )
+ assert result["plain"] == 18
+ assert result["band"] == [
+ "코드",
+ "품명",
+ "계약 수량",
+ "시공중 수량",
+ "예정공정 수량",
+ "실행공정 수량",
+ "기성 수량",
+ "PS 수량",
+ "선시공 수량",
+ "적용제외",
+ "적용수량",
+ ]
diff --git a/resources/tester/test_est_bid.py b/resources/tester/test_est_bid.py
index 2893b3ce2..65c83adee 100644
--- a/resources/tester/test_est_bid.py
+++ b/resources/tester/test_est_bid.py
@@ -11,6 +11,7 @@ FILES = [
"native-machine.js",
"native-rows.js",
"native-text.js",
+ "native-quantity-wpt.cjs",
"native-quantity.cjs",
"native-wonga.js",
"native-engine.js",
diff --git a/resources/tester/test_est_cleanup.py b/resources/tester/test_est_cleanup.py
index 11b77cfc0..582d22668 100644
--- a/resources/tester/test_est_cleanup.py
+++ b/resources/tester/test_est_cleanup.py
@@ -11,6 +11,7 @@ FILES = [
"native-machine.js",
"native-rows.js",
"native-text.js",
+ "native-quantity-wpt.cjs",
"native-quantity.cjs",
"native-wonga.js",
"native-engine.js",
diff --git a/resources/tester/test_est_docs.py b/resources/tester/test_est_docs.py
index e31f03f4a..8021b753e 100644
--- a/resources/tester/test_est_docs.py
+++ b/resources/tester/test_est_docs.py
@@ -14,6 +14,7 @@ FILES = [
"native-machine.js",
"native-rows.js",
"native-text.js",
+ "native-quantity-wpt.cjs",
"native-quantity.cjs",
"native-wonga.js",
"native-engine.js",
diff --git a/resources/tester/test_est_est_tools.py b/resources/tester/test_est_est_tools.py
index b4b68eac9..883144df4 100644
--- a/resources/tester/test_est_est_tools.py
+++ b/resources/tester/test_est_est_tools.py
@@ -11,6 +11,7 @@ FILES = [
"native-machine.js",
"native-rows.js",
"native-text.js",
+ "native-quantity-wpt.cjs",
"native-quantity.cjs",
"native-wonga.js",
"native-engine.js",
diff --git a/resources/tester/test_est_estimate_print.py b/resources/tester/test_est_estimate_print.py
index 3062c54a1..ddca91de7 100644
--- a/resources/tester/test_est_estimate_print.py
+++ b/resources/tester/test_est_estimate_print.py
@@ -11,6 +11,7 @@ FILES = [
"native-machine.js",
"native-rows.js",
"native-text.js",
+ "native-quantity-wpt.cjs",
"native-quantity.cjs",
"native-wonga.js",
"native-engine.js",
diff --git a/resources/tester/test_est_excel_map.py b/resources/tester/test_est_excel_map.py
index 1c389cdfd..c8d8ef7ba 100644
--- a/resources/tester/test_est_excel_map.py
+++ b/resources/tester/test_est_excel_map.py
@@ -11,6 +11,7 @@ FILES = [
"native-machine.js",
"native-rows.js",
"native-text.js",
+ "native-quantity-wpt.cjs",
"native-quantity.cjs",
"native-wonga.js",
"native-engine.js",
diff --git a/resources/tester/test_est_excel_to_est.py b/resources/tester/test_est_excel_to_est.py
new file mode 100644
index 000000000..bf4874a95
--- /dev/null
+++ b/resources/tester/test_est_excel_to_est.py
@@ -0,0 +1,36 @@
+"""EST 테스트 앱 엑셀데이타를 EST로 변환 7개 탭(원본 TfrmExcelToEst — 셀 규칙·머리 줄 꼴·호표 만들기)."""
+
+from test_est_rea_parity import run
+
+
+def test_convert_tabs_and_heads():
+ result = run(
+ "math",
+ r"""(()=>{
+ const X=EstExcelToEst;
+ const sheets={노임단가:[['L1',' 보통인부 ','','M2',172698,'']],
+ 견적단가:[['','.펌프','','1','EA','1,500','500','1,000','0','']],
+ 표준시장단가:[['C1','포장','t=5','m2',1000,300,600,100,0.3,'포함',''],],
+ 단가산출:[['','산근 1 호표 : 거푸집 합판 (1 ㎡ 당)',100,60,40,0],['','합판 1*2',40,0,40,0],['','#.산근2|철근가공|D10|ton',9,9,0,0]],
+ 일위대가:[['','No 5 호표 터파기','','','',0,10,0,10,0,0,0,0,''],['','인부','','0.1','인',100,10,100,10,0,0,0,0,''],['','[합계]','','','',0,10,0,0,0,0,0,0,'']]};
+ const cols=(n)=>Array.from({length:n},(_,i)=>String.fromCharCode(65+i));
+ const tabs={LaborcDat:{sheet:'노임단가',start:1,end:1,columns:cols(6)},EstiMateDat:{sheet:'견적단가',start:1,end:1,columns:cols(10)},
+ SilJukDat:{sheet:'표준시장단가',start:1,end:1,columns:cols(11)},CostMainDat:{sheet:'단가산출',start:1,end:3,columns:cols(6)},
+ HeadMainDat:{sheet:'일위대가',start:1,end:3,columns:cols(14)},StuffcDat:{sheet:'없는시트',start:1,end:1,columns:cols(18)}};
+ const r=X.convert(sheets,tabs,{name:'시험',wonga:[]}), t=r.project.tables;
+ return {failed:r.failed, lab:[t.LaborcDat[0].Name,t.LaborcDat[0].Unite,t.LaborcDat[0].TC],
+ est:[t.EstiMateDat[0].Name,t.EstiMateDat[0].TC,t.EstiMateDat[0].OTC,t.EstiMateDat[0].Code.slice(0,1)],
+ sil:[t.SilJukDat[0].NCRatio,t.SilJukDat[0].Cost1,t.SilJukDat[0].ONC],
+ cost:t.CostTitleDat.map(x=>[x.Name,x.Size,x.Unite]), line:t.CostMainDat[1].LineData,
+ head:[t.HeadTitleDat.map(x=>x.Name),t.HeadMainDat.length], rows:t.EstMainDat.length, auto:r.project.options.AutoCalc};
+ })()""",
+ )
+ assert result["failed"] == ["없는시트 시트가 없습니다."]
+ assert result["lab"] == ["보통인부", "㎡", 172698]
+ assert result["est"] == ["펌프", 1500, 1500, "N"]
+ assert result["sil"] == [30, 1000, 300]
+ assert result["cost"] == [["거푸집", "합판", "㎡"], ["철근가공", "D10", "ton"]]
+ assert result["line"] == "합판 1×2"
+ assert result["head"] == [["터파기"], 2]
+ assert result["rows"] == 36
+ assert result["auto"] is False
diff --git a/resources/tester/test_est_find.py b/resources/tester/test_est_find.py
index 5c2cc80d3..377e6fe6e 100644
--- a/resources/tester/test_est_find.py
+++ b/resources/tester/test_est_find.py
@@ -11,6 +11,7 @@ FILES = [
"native-machine.js",
"native-rows.js",
"native-text.js",
+ "native-quantity-wpt.cjs",
"native-quantity.cjs",
"native-wonga.js",
"native-engine.js",
diff --git a/resources/tester/test_est_jungsang.py b/resources/tester/test_est_jungsang.py
index 71650c642..d380e02a2 100644
--- a/resources/tester/test_est_jungsang.py
+++ b/resources/tester/test_est_jungsang.py
@@ -12,6 +12,7 @@ FILES = [
"native-machine.js",
"native-rows.js",
"native-text.js",
+ "native-quantity-wpt.cjs",
"native-quantity.cjs",
"native-wonga.js",
"native-jungsang.js",
diff --git a/resources/tester/test_est_name_reg.py b/resources/tester/test_est_name_reg.py
index ba807b62b..d5323eb07 100644
--- a/resources/tester/test_est_name_reg.py
+++ b/resources/tester/test_est_name_reg.py
@@ -11,6 +11,7 @@ FILES = [
"native-machine.js",
"native-rows.js",
"native-text.js",
+ "native-quantity-wpt.cjs",
"native-quantity.cjs",
"native-wonga.js",
"native-engine.js",
diff --git a/resources/tester/test_est_pick.py b/resources/tester/test_est_pick.py
index 282bbc673..2361a5fb3 100644
--- a/resources/tester/test_est_pick.py
+++ b/resources/tester/test_est_pick.py
@@ -11,6 +11,7 @@ FILES = [
"native-machine.js",
"native-rows.js",
"native-text.js",
+ "native-quantity-wpt.cjs",
"native-quantity.cjs",
"native-wonga.js",
"native-engine.js",
diff --git a/resources/tester/test_est_price_tools.py b/resources/tester/test_est_price_tools.py
new file mode 100644
index 000000000..45cfbca5b
--- /dev/null
+++ b/resources/tester/test_est_price_tools.py
@@ -0,0 +1,61 @@
+"""EST 테스트 앱 단가 도구(원본 중복 항목 정리 · 단가정의 엑셀 · 할증/낙찰/복원 · 항목선택계산 SUM 줄)."""
+
+import json
+import subprocess
+from pathlib import Path
+
+from test_est_rea_parity import run
+
+ROOT = Path(__file__).resolve().parents[2]
+
+
+def run_js(code: str):
+ runner = f"""
+ const fs = require('fs'), vm = require('vm');
+ const ctx = vm.createContext({{ structuredClone }});
+ vm.runInContext(fs.readFileSync('tools/est-test/native-price-tools.js', 'utf8'), ctx);
+ process.stdout.write(JSON.stringify(vm.runInContext({json.dumps(code)}, ctx)));
+ """
+ return json.loads(subprocess.check_output(["node", "-e", runner], cwd=ROOT).decode("utf-8"))
+
+
+def test_price_tools_rules():
+ out = run_js(
+ r"""(() => {
+ const P = EstPriceTools;
+ const same = P.samecheck([{ Name: '철 근', Size: 'D10', Unite: 'ton', TC: 5 }, { Name: '철근', Size: 'D10', Unite: 'ton', TC: 9 },
+ { Name: '철근|', Size: 'D10', Unite: 'ton', TC: 9 }, { Name: '모래', TC: 1 }]);
+ const g = P.gungu([{ Code: 'C1', Index: 3, Name: '포장', Size: 't=5', Unite: 'm2', EXPLEAN: '포함: 재료\n제외: 운반' }, { Code: 'C2', Name: '빈' }]);
+ const rows = [{ Name: 'a', TC: 100 }, { Name: 'b', TC: 200, OTC: 210 }, { Name: '', TC: 0 }];
+ const n = P.setRate(rows, [], 'NachalRatio', 88);
+ const one = [{ Name: 'x', TC: 1 }, { Name: 'y', TC: 2 }]; P.setRate(one, [1], 'Ratio', 5);
+ const r = { Name: '', Size: '', LinkIndex: 0 };
+ P.sumRow('HeadMainDat', r, { name: '기구손료', size: '', rate: 2, base: 2, target: 3, picked: [1, 3] });
+ const errs = [];
+ for (const [row, o] of [[{ Index: 1 }, {}], [{}, { base: 0, target: 2, picked: [1] }], [{}, { base: 1, target: 1, picked: [] }],
+ [{}, { base: 1, target: 1, picked: [1, 2, 3, 4, 5, 6] }]])
+ try { P.sumRow('HeadMainDat', row, { name: '', size: '', rate: 1, ...o }); } catch (e) { errs.push(e.message.slice(0, 8)); }
+ return { same: [same.removed, same.rows.map((x) => [x.Name, x.TC])], g, n, rows: rows.map((x) => [x.NachalRatio ?? null, x.OTC ?? null]),
+ one: one.map((x) => x.Ratio ?? null), r: [r.Code, r.LinkIndex, r.GN, r.Biul, r.Unite], errs };
+ })()"""
+ )
+ assert out["same"] == [2, [["철근|", 9], ["모래", 1]]]
+ assert out["g"] == [["C1", "3. 포장 (t=5) [m2]"], ["", "포함: 재료"], ["", "제외: 운반"], ["", ""]]
+ assert out["n"] == 3
+ assert out["rows"] == [[88, 100], [88, 210], [None, None]]
+ assert out["one"] == [5, 5]
+ assert out["r"] == ["A0103", 62, 2, 0.02, "%"]
+ assert out["errs"] == ["현재 라인에는 ", "비목별항목은 비", "더할항목의 색인", "더할항목의 색인"]
+
+
+def test_sum_row_code_numbers_select_groups():
+ result = run(
+ "math",
+ """(()=>{
+ const rows=[{Index:1,Code:'H'},{Code:'a',GN:1,NC:100,NU:100,SKind:1},{Code:'b',GN:1,NC:50,NU:50,SKind:3},
+ {Code:'c',GN:1,NC:7,NU:7,SKind:2},{Code:'A0103',LinkIndex:62,GN:10,OCheck:2,SCheck:2}];
+ const out=EstRows.head(rows,{HeadDigits:0});
+ return out.rows?out.rows[4].NC:out[4].NC;
+ })()""",
+ )
+ assert result == 15
diff --git a/resources/tester/test_est_print_batch.py b/resources/tester/test_est_print_batch.py
index b49dddc08..62a916746 100644
--- a/resources/tester/test_est_print_batch.py
+++ b/resources/tester/test_est_print_batch.py
@@ -11,6 +11,7 @@ FILES = [
"native-machine.js",
"native-rows.js",
"native-text.js",
+ "native-quantity-wpt.cjs",
"native-quantity.cjs",
"native-wonga.js",
"native-engine.js",
diff --git a/resources/tester/test_est_print_format.py b/resources/tester/test_est_print_format.py
new file mode 100644
index 000000000..f0c9bedce
--- /dev/null
+++ b/resources/tester/test_est_print_format.py
@@ -0,0 +1,34 @@
+"""EST 테스트 앱 인쇄양식 파일(.pfot — 원본 mnuPrintFormatSave·Load) 읽기·쓰기."""
+
+import json
+import subprocess
+from pathlib import Path
+
+import pytest
+
+ROOT = Path(__file__).resolve().parents[2]
+PFOT = Path("C:/Dasansoft/Est Plus 2/System/EstFormat")
+
+
+def test_pfot_round_trip_is_byte_identical_and_edits_apply():
+ if not (PFOT / "main.pfot").exists():
+ pytest.skip("installed EST print formats unavailable")
+ code = r"""
+ const P = require(process.cwd() + '/tools/est-test/print-format.cjs'), fs = require('fs');
+ const out = {};
+ for (const f of ['main.pfot', 'main(가로양식).pfot']) {
+ const b = fs.readFileSync(process.argv[1] + '/' + f), rows = P.readFull(b);
+ out[f] = [rows.length, P.write(b, rows).equals(b)];
+ }
+ const b = fs.readFileSync(process.argv[1] + '/main.pfot'), rows = P.readFull(b);
+ rows[1].Name2 = '원가계산서(수정)'; rows[1].Checked = 0;
+ const again = P.read(P.write(b, rows));
+ out.edit = [again[1].Name2, again[1].Checked, again.length];
+ process.stdout.write(JSON.stringify(out));
+ """
+ out = json.loads(
+ subprocess.check_output(["node", "-e", code, str(PFOT)], cwd=ROOT).decode("utf-8")
+ )
+ assert out["main.pfot"] == [41, True]
+ assert out["main(가로양식).pfot"][1] is True
+ assert out["edit"] == ["원가계산서(수정)", 0, 41]
diff --git a/resources/tester/test_est_progress_wonga.py b/resources/tester/test_est_progress_wonga.py
index be4c1f60f..e1715f56e 100644
--- a/resources/tester/test_est_progress_wonga.py
+++ b/resources/tester/test_est_progress_wonga.py
@@ -11,6 +11,7 @@ FILES = [
"native-machine.js",
"native-rows.js",
"native-text.js",
+ "native-quantity-wpt.cjs",
"native-quantity.cjs",
"native-wonga.js",
"native-engine.js",
diff --git a/resources/tester/test_est_quantity_table.py b/resources/tester/test_est_quantity_table.py
new file mode 100644
index 000000000..a907ddaf4
--- /dev/null
+++ b/resources/tester/test_est_quantity_table.py
@@ -0,0 +1,71 @@
+"""EST 테스트 앱 워드형 수량산출서 표(원본 WPT
보존 · 방향 셀 합 · 수량 표지 · 세로 병합)."""
+
+import json
+import subprocess
+from pathlib import Path
+
+import pytest
+
+ROOT = Path(__file__).resolve().parents[2]
+MAIN = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx")
+
+
+def node(code: str, *args: str):
+ return json.loads(
+ subprocess.check_output(["node", "-e", code, *args], cwd=ROOT).decode("utf-8")
+ )
+
+
+def test_table_parse_sum_values_merge():
+ out = node(r"""
+ const W = require(process.cwd() + '/tools/est-test/native-quantity-wpt.cjs');
+ const wpt = '' +
+ '단가 9
' +
+ '| 공종 | 수량 |
' +
+ '| 절단 | 1,000.5 |
' +
+ '| 깨기 | 2 |
| 계 | |
';
+ const s = W.fromWpt('N1', '시험', wpt, [], {});
+ const t = s.rows[1].table;
+ const div = s.rows[0].LineData;
+ t[3][1].formula = W.sumFormula(t, 3, 1, 'down');
+ const values = Object.fromEntries(W.values(s));
+ const right = W.sumFormula(t, 3, 1, 'right');
+ W.vmerge(t, 1, 2, 0);
+ const merged = [t[1][0].text, t[2][0].vmerge, t[1][0].vtop];
+ W.vsplit(t, 1, 0);
+ process.stdout.write(JSON.stringify({ rows: s.rows.length, div, cell: t[1][1], name: t[1][0].name,
+ formula: t[3][1].formula, sum: t[3][1].text, values, right, merged, split: t[2][0].vmerge ?? null,
+ created: W.create(2, 3).map((r) => r.length) }));
+ """)
+ assert out["rows"] == 2
+ assert out["div"] == "단가 9" # 문단의 G 표지는 표지로 보지 않음
+ assert out["cell"] == {"text": "1,000.5", "mark": "G00000203"}
+ assert out["name"] is True
+ assert out["formula"] == "SUM(B2:B3)" and out["sum"] == "1,002.5"
+ assert out["values"] == {"G00000203": 1000.5}
+ assert out["right"] == "SUM(A4:A4)"
+ assert out["merged"] == ["절단 깨기", True, 2]
+ assert out["split"] is None
+ assert out["created"] == [3, 3]
+
+
+def test_installed_main_tables_survive_import():
+ if not MAIN.exists():
+ pytest.skip("installed EST main sample unavailable")
+ out = node(
+ r"""
+ const { importEstx } = require(process.cwd() + '/tools/est-test/import-estx.cjs');
+ const p = importEstx(require('fs').readFileSync(process.argv[1]));
+ const tables = p.quantitySheets.filter((s) => s.rows.some((r) => r.table))
+ .map((s) => { const t = s.rows.find((r) => r.table).table; return [s.Code, t.length, t[0].length]; });
+ process.stdout.write(JSON.stringify(tables));
+ """,
+ str(MAIN),
+ )
+ assert out == [
+ ["N0000000097", 9, 13],
+ ["N0000000108", 7, 5],
+ ["N0000000109", 5, 5],
+ ["N0000000110", 7, 5],
+ ["N0000000111", 8, 5],
+ ]
diff --git a/resources/tester/test_est_quantity_total.py b/resources/tester/test_est_quantity_total.py
index c9873a046..4b7e24b23 100644
--- a/resources/tester/test_est_quantity_total.py
+++ b/resources/tester/test_est_quantity_total.py
@@ -10,6 +10,7 @@ FILES = [
"native-formula.js",
"native-text.js",
"native-quantity-total.js",
+ "native-quantity-wpt.cjs",
"native-quantity.cjs",
]
SCRIPT = r"""(() => {
diff --git a/resources/tester/test_est_sources.py b/resources/tester/test_est_sources.py
index 0d8627572..c1065e40d 100644
--- a/resources/tester/test_est_sources.py
+++ b/resources/tester/test_est_sources.py
@@ -11,6 +11,7 @@ FILES = [
"native-machine.js",
"native-rows.js",
"native-text.js",
+ "native-quantity-wpt.cjs",
"native-quantity.cjs",
"native-wonga.js",
"native-sources.js",
diff --git a/resources/tester/test_est_tidy.py b/resources/tester/test_est_tidy.py
index 38cdd15ca..bf7d8981f 100644
--- a/resources/tester/test_est_tidy.py
+++ b/resources/tester/test_est_tidy.py
@@ -11,6 +11,7 @@ FILES = [
"native-machine.js",
"native-rows.js",
"native-text.js",
+ "native-quantity-wpt.cjs",
"native-quantity.cjs",
"native-wonga.js",
"native-engine.js",
diff --git a/resources/tester/test_est_transfer.py b/resources/tester/test_est_transfer.py
index d581fc95d..70c1a8055 100644
--- a/resources/tester/test_est_transfer.py
+++ b/resources/tester/test_est_transfer.py
@@ -11,6 +11,7 @@ FILES = [
"native-machine.js",
"native-rows.js",
"native-text.js",
+ "native-quantity-wpt.cjs",
"native-quantity.cjs",
"native-wonga.js",
"native-engine.js",
diff --git a/resources/tester/test_est_wonga_tools.py b/resources/tester/test_est_wonga_tools.py
index 4175df2f8..f64eb1396 100644
--- a/resources/tester/test_est_wonga_tools.py
+++ b/resources/tester/test_est_wonga_tools.py
@@ -11,6 +11,7 @@ FILES = [
"native-machine.js",
"native-rows.js",
"native-text.js",
+ "native-quantity-wpt.cjs",
"native-quantity.cjs",
"native-wonga.js",
"native-engine.js",
diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md
index 37ed2916b..5438e7a19 100644
--- a/tools/est-test/PLAN.md
+++ b/tools/est-test/PLAN.md
@@ -489,6 +489,27 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD
- 남음: 구성비·요율 전환(CalcBase), 약어 표시(SName), 제비율 기준 문서 보기(원본 한글 파일).
- [x] 수량산출서 검색·전체보기(원본 TFRMMULLANG btnSerch·btnAllDisplay — 빈칸·|·괄호 무시 포함 검색) — 고르기 줄을 `native-quantity-sheets.js` 로 옮겨 700줄 제한 유지.
- 검증완료: `test_est_quantity_sheets.py` 검색 시험 포함 수량 시험 22개 통과. ORCA 브라우저에서 「집수」 검색 → 항목에 집수정이 있는 「배수」만·선택 이동, 전체보기 → 3개 복귀, 샘플 복구.
+ - [x] 표지·갑지 상세편집·용지모양, 산출 줄 글자모양, 수량산출서 인쇄, 엑셀형 셀 테두리·음영·수량붙여넣기(연결)(spec_pc §5 · spec_pa §1·6·8·9) — 상세편집: 인쇄 모양을 자유 편집해 저장하면 인쇄가 그 모양을 씀(해제 가능), 용지모양은 인쇄 창 용지·여백. 글자모양: 산출 줄 크기·굵게·색. 수량산출서 인쇄: 현재 또는 모두(간지 고르기), 줄·표·그림 그대로. 엑셀형: 셀 테두리·음영, 수량 목록에서 담은 항목을 고른 셀부터 가로/세로·명칭/규격/단위/수량으로(자기 산출서 거부, 수량 칸은 원본 항목 연결 기록).
+ - 검증완료: 문서·바인딩 시험 4개 통과. ORCA 브라우저에서 갑지 상세편집 제목 고침 → 인쇄 html 반영, 글자모양 18px·굵게·#800000, 인쇄 창 표·간지, 엑셀형 B2·B3 에 2.5·0.19(연결 기록), 샘플 복구.
+ - [x] 물가변동 적용대가 보기 변경·수량불러오기·적용대가집계표(spec_pb1 §5·§6) — 보기 변경: 단가 칸 숨기고 계약·구분별 수량 7종만. 수량불러오기: 수량분 7종 중 고른 뒤 내역서 파일의 행 i 수량을 이 내역 행 i 구분 칸에(계약분은 계약 확정본, 없으면 당초 칸), 그 파일 원가 행 i 금액을 원가 행 Es*Cost 에. 집계표: 원가 행마다 계약·적용제외·적용대가 금액(계약금액은 불러온 값, 없으면 계약 내역 계산).
+ - 검증완료: 적용대가 시험 3개 통과. ORCA 브라우저에서 보기 변경 머리 11칸·집계표 31행 확인.
+ - [x] 기성 환경설정(spec_pb1 §4) — 공통적용(코드·수량 0·금액 0·비목순서·품명정렬·두줄·Enter 뒤 셀 이동·바탕색)은 계산 환경 Display, 기성고(레벨·비고란 발췌근거=비고·집계공종 진하게·사정금액·노재경 구분·실적 비목)는 AccomOption. 칸을 바꾸면 바로 화면에, 취소면 되돌림. 주 메뉴 환경설정(기성 화면)·기성 원가 「환경 설정」·Ctrl+U. 원본도 처리기 없는 자동저장 분·계산 확인·가로 계산방법·재료비 처리·이윤 사사오입은 값만.
+ - 검증완료: 시험 2개 통과. ORCA 브라우저에서 코드 끔·레벨·노재경 구분·사정 끔·수량 0 숨김 반영과 취소 원복 확인, 임시 회차·옵션 원복.
+ - [x] 도급내역 선택(spec_pb1 §3) — 설계변경 내역서에서 기성 회차를 추가하면 당초/변경 고르기. 당초면 행마다 당초 칸(_)으로 Index·품명·규격·단위(당초가 비면 지금 값)·수량·단가·금액. 웹은 `_` = 당초(설계변경 시작 때 복사본) 해석을 따름(spec_pb1 은 원본 분기상 `_` = 변경으로 읽음 — 실무 샘플 3건의 `_` 값이 서로 같아 당초 쪽으로 판단). 원본의 GC 누락 결함은 재현하지 않음.
+ - 검증완료: 시험 1개 통과(당초 수량 4·변경 10). ORCA 브라우저에서 고르기 창·회차 loadMethod 확인, 원복.
+ - [x] 다른 공사 불러오기 창(원본 barbtnSelfLoad → TfrmOtherLoad, 찾기 popSerch) — 저장된 다른 작업을 골라 같은 표를 띄우고 찾기(품명·규격, A&B 둘 다)·고른 줄을 하위 자료와 함께 가져오기(내역서는 고른 줄 아래에 줄, 나머지는 자료·호표 합침, 같은 코드는 이 공사 것). 검증완료: 바인딩 통과, ORCA 브라우저에서 산불진화임도 작업의 일위대가 3개 → 「취수장」 1개 → 가져오기 호표 8→9·하위 41건, 샘플 복구.
+ - [x] 사용위치 검색 창(원본 TfrmUsesDataSerch btnSerch·btnAllDisplay) — 현재 표 항목을 품명·규격(빈칸·|·괄호 무시)으로 거르고 골라 사용위치 보기. 검증완료: 바인딩 통과, ORCA 브라우저에서 노임 9개 → 「보 통」 1개 → 보통인부 사용위치 11곳.
+ - [x] 인쇄양식 불러오기·저장하기(원본 mnuPrintFormatLoad·Save 0x10ed318·0x10ed000 — spec_pc §3) — .pfot(인쇄정보표 MIDAS 이진) 읽기·쓰기 `print-format.cjs`(설명 BLOB·빈 칸 표지까지 보존, 원본 파일 그대로 다시 쓰면 바이트 같음). 불러오면 공사 인쇄표를 그 값으로, 저장은 지금 인쇄표를 원본 설치 main.pfot 필드 설명자 위에(`EST_PFOT_TEMPLATE`). .gfot(cxGrid 열 배치)는 웹 격자와 호환되지 않아 하지 않음.
+ - 검증완료: `test_est_print_format.py`(설치 .pfot 두 개 바이트 같음·고친 값 반영) 및 바인딩 시험 통과, 서버 경로로 읽고 다시 쓴 파일 cmp 같음. ORCA 브라우저에서 샘플 인쇄양식 저장(32,755 바이트).
+ - [x] 엑셀데이타를 EST로 변환 7개 탭(원본 TfrmExcelToEst 0x109c3bc — spec_pc §1) — 노임·자재·견적·표준시장·단가산출·일위대가·내역서 탭마다 시트명·시작/끝행·필드별 열 글자(.xlsx·.xls), 노임 → 자재 → 견적 → 표준 → 단산 → 일위 → 내역 차례로 새 공사(현재 원가 서식, 자동계산 끔 — Excel 값 그대로). 셀 규칙(Trim·줄바꿈·앞 「.」·㎡/㎥·「*」→「×」·쉼표·노무비율 ×100·「B:D」 이어붙임), 노임 단가 → Cost1·TC, 견적·표준 → O 칸 함께, 빈 코드 N+10자리, 내역 36행 채움. 단가산출·일위대가 머리 줄 꼴로 호표 만들기(코드·TitleCode·Index·LineData 품명/규격/단위 표지, [합계] 줄 지움), 못 읽은 시트 안내. 하지 않음: 글자 굵기 판정·고정폭 머리말·단가산출 목록 시트형(굵기·폭 정보 없음).
+ - 검증완료: `test_est_excel_to_est.py`(없는 시트 안내, 노임 ㎡·TC, 견적 앞점·쉼표·O칸·N코드, 표준 노무비율 30, 단산 호표 2개·×, 일위 호표·[합계] 지움, 36행, 자동계산 끔) 통과. ORCA 브라우저에서 탭 7개·단가산출 열 6칸 표시(파일 고르기는 수동).
+ - [x] 전회기성 불러오기·조달수수료·원가에 내역 금액·원가 구성비(spec_pb1 1·9·10 · spec_pb2 8) — 전회기성: 완료 회차 아무것이나 골라 그 누계를 전회로(현재 회차 거부, `round.junFrom`). 조달수수료 입력(Ctrl+J): 현재 줄을 Code AJJ01·LinkIndex 57·GN %·자리방법 Index·자리수 Gangub·계산 비목 OCheck·저장 칸 SCheck 로(57 줄이면 수정, 허용 검사·원본 문구). 원가계산서에 내역 금액 넣기: 원본은 끌어 놓기 — 웹은 줄 고르기 + 합계/노무/재료/경비 고르기 → 원가 줄 Cost 고정(LinkIndex 1, 덮어쓰기 확인). 구성비 표시: 원가 요율 칸을 「비율」로 바꿔 기준 행(CalcBase 또는 도급액) 대비 대상 비목 Cost 를 0.01% 절사·최대 나머지로 100% 맞춤(계산 불변).
+ - 검증완료: 기성·바인딩 시험 14개 통과. ORCA 브라우저에서 구성비 20줄 합 100.00%·머리 「비율」, 내역 금액 넣기 노무비 384,804, Ctrl+J 조달수수료 AJJ01·57·0.54%·절하, 샘플 복구.
+ - [x] 단가 도구(spec_pb2) — 중복 항목 정리(자재단가: 품명·규격·단위 빈칸·| 무시 같으면 TC 가장 큰 행만, 같으면 뒤 행, 묻지 않음), 단가정의 엑셀변환(표준시장단가 설명문 있는 항목만 「코드 / 번호. 품명 (규격) [단위]」+설명 줄, .xlsx), 할증·낙찰(고른 2행 이상 또는 전 행 Ratio·NachalRatio, 원래 단가 OTC 보관, 100 이면 자리 해제)·복원(두 율 0), 바꾸기(Main Data 로 현재 표 하나만 갱신, 못 찾음 표시), SUM(현재 줄을 항목선택계산 62 줄로: Code 'A'+두 자리 색인·적용율·비목, 원본 거부 문구, 줄 자신 SKind = 묶음 최대+1). 엔진: 62 줄 Code 를 두 자리 색인 번호로 읽음(설치 main.estx 의 「A01」 꼴, 예전 글자 해석은 A0103 의 C 를 놓쳤음).
+ - 검증완료: `test_est_price_tools.py` 2개(중복 2개 지움·단가정의 줄·율 쓰기 범위·SUM Code A0103·거부 4종, 엔진 A0103 → 색인 1·3 합 150×10% = 15) 및 REA 대조 39개 통과. ORCA 브라우저에서 보통인부 낙찰 90% 172,068→154,861, 복원 →172,068, 바꾸기(Main) → 2026 하반기 값, 샘플 복구.
+ - [x] 워드형 수량산출서 표와 편집 도구(원본 TFRMMULWORD·TFRMMULLANG — spec_word · spec_pa) — 가져오기가 WPT 을 통째로 버리던 결함 고침: 표를 한 줄 { table: [[칸]] }(글·수량 표지 r/L/G·품명/규격 표지·수식·세로병합)로 보존(설치 main.estx 표 5개 9×13~5×5). 문서 읽기는 `native-quantity-wpt.cjs` 로 분리. 표 화면: 칸 고르기(Shift 범위)·글/수식 고치기·방향 셀 합(우·좌·위·아래, 원본 범위 규칙·1,234.5 꼴)·수량으로 지정/해제·셀수직병합/분리·표 모양(테두리)·아래 표 만들기. 줄 도구: 문단(정렬·들여쓰기)·그림 넣기·한페이지 삽입/삭제(웹 한 쪽 45줄)·품명지정/규격지정(한 줄 하나·40자·제목 위 금지·원본 문구)·테두리선·산출근거 양식(화면 표시)·공종명칭 입력(머리 제목 줄 굵게 큰 글자). 수량 불러오기(Main Data 산출서 75개에서 새 산출서로), 수량목록보기(모든 산출서·수량 항목·원문 미리보기·검색·선택수량복사), 내역서 수량붙여넣기(연결)(고른 줄부터 아래로 수량·연결, 품명,규격입력 체크 시 이름도). 표·서식을 고친 산출서는 ESTX 저장 거부(JSON), 고치지 않은 표 산출서는 ESTX 저장이 표 줄을 건너 문단만 맞춤.
+ - 검증완료: `test_est_quantity_table.py` 2개(표 읽기·문단 G 표지 무시·아래 합 SUM(B2:B3)=1,002.5·수량 값·병합/분리·설치 표 5개) 및 수량·ESTX 내보내기 회귀 49개 통과. ORCA 브라우저에서 보차도경계석 보수 표 35칸, 수량 1.0→2.5 항목 수량 반영, 아래 합 SUM(D2:D6)=3.923, 수량 불러오기 75개·문단 가운데·산출근거 양식·쪽 삽입 45줄·삭제, 공종명칭 「보차도경계석 보수(L=1m)」, 품명지정 ⟦품명⟧, 수량목록 복사 → 내역 붙여넣기 1·0.19·연결 id:36, 샘플 복구.
+ - 남음: 글자모양(글꼴·색), 수량산출서 원본 모양 인쇄(쪽·간지), 엑셀형 시트 수량붙여넣기(연결).
- [x] 연차 내역서·상단·하단데이타 불러오기(원본 TFRMESTMULTIEDIT popLoadEst·popUpDataLoad·popDownLoad 0xedb784 → 0xf11a60 — spec_part §7) — 차수 줄의 세 단추: 고른 .estx 내역서 줄을 차수에 짝지어 옮기고(행 수는 현재 내역), 수식칸에 파일 이름. 상단·하단은 설계변경 내역만(당초 「_」 칸 있는 것), 상단 = 당초값·하단 = 변경값. 그 차수의 상세 줄은 수식 대신 불러온 값(금액 고정, 다시 셈 없음). 서버 계산도 같은 코드(`native-annual-load.js`).
- 검증완료: 기성·연차 시험 13개(불러온 줄 수량 3·2·직접공사비 400, 수식 무시) 통과. 당초/변경 고르기·설계변경 아닌 파일 거부 확인. ORCA 브라우저에서 차수 줄에 불러오기 단추 3개 표시(파일 선택은 수동 조작 필요).
- [x] 현재데이타 갱신(원본 TFRMDATAUPDATE btnApply 0x10bc3d4 → 엔진 0x10e12c8 — spec_update) — 원천: Main Data(원본 설치 main.estx = BufDat\Main\Main_1, 서버 `/api/main-data`, `EST_MAIN_DATA` 로 경로 변경) 또는 고른 .estx. 항목: 노임·자재(유류 제외)·유류(이름 8종)·표준(코드 일치)·기계(손료계수·환율 표 교체, 기계 호표 이름·규격 일치, 산출근거 줄 교체 뒤 자재·노임 다시 잇기)·환율(36·89·69·77·70·76). 맞춤 규칙·복사 칸·단가미갱신 표시·안내 문구 원본대로. 행 추가·삭제 없음. 갱신 뒤 재계산. 하지 않음: 최신(웹접속 — 원본 회사 서버·유지보수 인증), 자재 단가열 출처 재배치(OtherTables — 같은 열 순서 가정), 원본 결함(환율 끔 때 파운드를 76 값으로 덮음).
diff --git a/tools/est-test/export-quantity.cjs b/tools/est-test/export-quantity.cjs
index 1b92530de..0ca3e0e0e 100644
--- a/tools/est-test/export-quantity.cjs
+++ b/tools/est-test/export-quantity.cjs
@@ -211,7 +211,12 @@ function editDocument(text, oldSheet, sheet) {
);
});
if (rowIndex !== oldSheet.rows.length) throw Error("수량산출서 행 수 불일치");
- if (!same(quantity.fromWpt(sheet.Code, sheet.Name, result, sheet.items, sheet.options), sheet))
+ if (
+ !same(
+ withoutTables(quantity.fromWpt(sheet.Code, sheet.Name, result, sheet.items, sheet.options)),
+ sheet,
+ )
+ )
throw Error("수량산출서 문서 재읽기 검산 실패");
return result;
}
@@ -414,10 +419,34 @@ function reorderRows(text, sheet, rows) {
return { text: document, rows: order.map((source) => sheet.rows[source]) };
}
+// 워드형 산출서의 표 줄(row.table)은 문단 줄과 따로 다룸: 표는 고칠 수 없으므로(tableEdited 거부) 같은 자리에 그대로 있어야 하고,
+// 문단 줄 처리 동안은 빼 두었다가 저장 결과에 다시 넣는다.
+const tableRows = (sheet) => sheet.rows.flatMap((row, k) => (row.table ? [[k, row]] : []));
+const withoutTables = (sheet) =>
+ sheet.rows.some((row) => row.table)
+ ? { ...sheet, rows: sheet.rows.filter((row) => !row.table) }
+ : sheet;
+function withTables(sheet, tables) {
+ if (!tables.length) return sheet;
+ const rows = sheet.rows.slice();
+ for (const [k, row] of tables) rows.splice(k, 0, row);
+ return { ...sheet, rows };
+}
+
function updateQuantities(project, base, cp949, memory) {
- const sheets = project.quantitySheets || [],
- originals = base.quantitySheets || [];
+ const fullSheets = project.quantitySheets || [],
+ fullOriginals = base.quantitySheets || [];
+ fullSheets.forEach((sheet, i) => {
+ if (fullOriginals[i] && !same(tableRows(sheet), tableRows(fullOriginals[i])))
+ throw Error(
+ "표가 있는 산출서의 줄 추가·삭제·이동은 ESTX 저장을 지원하지 않습니다. JSON으로 저장하세요.",
+ );
+ });
+ const sheets = fullSheets.map(withoutTables),
+ originals = fullOriginals.map(withoutTables);
if (sheets.length !== originals.length) throw Error("수량산출서 추가·삭제는 지원하지 않습니다.");
+ if (sheets.some((sheet) => sheet.tableEdited))
+ throw Error("워드형 산출서의 표 편집은 ESTX 저장을 지원하지 않습니다. JSON으로 저장하세요.");
if (sheets.some((sheet) => sheet.excelEdited))
throw Error("엑셀형 수량산출서 편집은 ESTX 저장을 지원하지 않습니다. JSON으로 저장하세요.");
if (sheets.some((sheet) => sheet.markerEdited))
@@ -570,12 +599,14 @@ function updateQuantities(project, base, cp949, memory) {
document = insertRows(document, sheets[i], inserts);
if (
!same(
- quantity.fromWpt(
- sheets[i].Code,
- sheets[i].Name,
- document,
- sheets[i].items,
- sheets[i].options,
+ withoutTables(
+ quantity.fromWpt(
+ sheets[i].Code,
+ sheets[i].Name,
+ document,
+ sheets[i].items,
+ sheets[i].options,
+ ),
),
sheets[i],
)
@@ -667,7 +698,9 @@ function updateQuantities(project, base, cp949, memory) {
}
return {
...project,
- quantitySheets: savedSheets,
+ quantitySheets: savedSheets.map((sheet, i) =>
+ sheet === sheets[i] ? fullSheets[i] : withTables(sheet, tableRows(fullSheets[i])),
+ ),
tables: { ...project.tables, MulMainDat: rows, MulTitleDat: titles },
};
}
diff --git a/tools/est-test/index.html b/tools/est-test/index.html
index 29f6f57cd..92c644039 100644
--- a/tools/est-test/index.html
+++ b/tools/est-test/index.html
@@ -13,7 +13,17 @@
+
+
+
+
+
+
+
+
+
+
@@ -106,7 +116,9 @@
-
+
@@ -154,7 +166,12 @@
>
+ >
@@ -302,8 +319,18 @@
> {
+ const modules = () =>
+ [
+ typeof EstQuantityList !== "undefined" && EstQuantityList,
+ typeof EstPriceTools !== "undefined" && EstPriceTools,
+ typeof EstMiscTools !== "undefined" && EstMiscTools,
+ typeof EstExcelToEst !== "undefined" && EstExcelToEst,
+ typeof EstOtherLoad !== "undefined" && EstOtherLoad,
+ typeof EstAppliedCost !== "undefined" && EstAppliedCost,
+ typeof EstProgressView !== "undefined" && EstProgressView,
+ ].filter(Boolean);
+ function handle(name, state, node) {
+ for (const module of modules()) if (module.handle(name, state, node)) return true;
+ return false;
+ }
+ return { handle };
+})();
diff --git a/tools/est-test/native-actions.js b/tools/est-test/native-actions.js
index d01a765b0..3dae41fab 100644
--- a/tools/est-test/native-actions.js
+++ b/tools/est-test/native-actions.js
@@ -333,7 +333,8 @@ const EstActions = (() => {
"REA 샘플 대조",
`${result.checked}개 중 ${result.passed}개 일치 · 불일치 ${result.failed}개
보관된 EST 샘플의 저장값과 계산 결과를 비교했습니다. 원본 실행 비교는 포함하지 않습니다.
${result.errors.length ? `${esc(result.errors.join(" / "))}
` : ""}| 영역 | 검사 수 | 일치 |
${[...new Set(result.results.map((r) => r.group))].map((group) => `| ${esc(group)} | ${result.results.filter((r) => r.group === group).length} | ${result.results.filter((r) => r.group === group && r.pass).length} |
`).join("")}
`,
);
- } else if (name === "settings") EstSettings.show();
+ } else if (name === "settings")
+ EstApp.state.view === "progress" ? EstProgressView.dialog(EstApp.state) : EstSettings.show();
else if (name === "raw-line") {
const row = activeRows()[Number(node.dataset.row)];
showDialog(
@@ -427,19 +428,8 @@ const EstActions = (() => {
const totals = EstQuantity.totals(project, state.quantitySheet);
if (confirm("연결된 내역 수량을 산출 결과로 바꾸시겠습니까?"))
EstApp.change(() => EstQuantity.applyTotals(project, totals));
- } else if (name === "add-round") {
- const next = {
- id: EstApp.uid(),
- name: EstBusiness.newProgressName(project.progress),
- inputs: {},
- approved: false,
- locked: false,
- };
- EstApp.change(() => {
- project.progress.push(next);
- state.roundId = next.id;
- }, false);
- } else if (name === "lock-round") {
+ } else if (name === "add-round") EstProgressView.addRound(state);
+ else if (name === "lock-round") {
if (
confirm("이 회차를 확정하고 저장하시겠습니까? 전회 누계로 보존되고 직접 편집이 잠깁니다.")
) {
diff --git a/tools/est-test/native-applied-cost.js b/tools/est-test/native-applied-cost.js
new file mode 100644
index 000000000..095995623
--- /dev/null
+++ b/tools/est-test/native-applied-cost.js
@@ -0,0 +1,236 @@
+// 원본 「물가변동 적용대가 분류 내역서」(TfrmAppliedCost — spec_pb1 §5·§6) 화면: 품목조정 표(native-extra.js 에서 옮김),
+// 「적용대가 보기 변경」(cmStyleToggle 0x10b5918 → e43b48: 단가 밴드를 숨기고 계약·구분별 수량 7종만 — 값 변화 없음),
+// 「수량불러오기」(cmGNLoad 0x10b57a4 → 10b6630·10b5934·10b617c: 고른 내역서의 행 i 수량 GN 을 이 내역 행 i 의 구분 칸에,
+// 그 파일 원가 행 i 의 Cost 를 원가 행 i 의 Es*Cost 에 — 위치 짝, 원본 행이 없으면 건너뜀),
+// 「적용대가집계표」(mnuESWonga → TfrmESPWonga: 원가 행마다 계약금액·적용제외금액·적용대가금액 — 계산은 품목조정과 같은 값).
+const EstAppliedCost = (() => {
+ const num = (v) => Number(v) || 0;
+ const QTY = ["EscConstruction", "EscPlanned", "EscActual", "EscProgress", "EscPS", "EscBefore"];
+ const QTY_LABELS = [
+ "시공중 수량",
+ "예정공정 수량",
+ "실행공정 수량",
+ "기성 수량",
+ "PS 수량",
+ "선시공 수량",
+ ];
+ const KINDS = [
+ "계약분 수량",
+ "시공중인공사계약분 수량",
+ "예정공정율분 수량",
+ "실행공정율분 수량",
+ "기성율분 수량",
+ "PS분 수량",
+ "신시공부분 수량",
+ ];
+ const COST = [
+ "EsContractCost",
+ "EsSiGongCost",
+ "EsSchduleCost",
+ "EsWorkCost",
+ "EsACCost",
+ "EsPSCost",
+ "EsBeforeCost",
+ ];
+
+ function view(state, project, { box, button, table }) {
+ const { esc, number } = EstApp;
+ const output = EstBusiness.escalation(project);
+ state.business = output;
+ const comp = state.escalationComponent || "NU";
+ const choices = EstMath.units
+ .map(
+ (key, i) =>
+ ``,
+ )
+ .join("");
+ const page = EstBlockActions.escalationPage(state, project.tables.EstMainDat);
+ const qty = state.escQtyView;
+ const rows = page.rows.map(({ row, index }) => {
+ const result = output.rows[index];
+ if (qty)
+ return [
+ esc(row.Code),
+ esc(row.Name),
+ number(num(result.EscApplied) + num(result.EscExcluded)),
+ ...QTY.map((k) => EstViews.cell("EstMainDat", index, k, row[k] ?? 0)),
+ number(result.EscExcluded),
+ number(result.EscApplied),
+ ];
+ return [
+ esc(row.Code),
+ esc(row.Name),
+ EstViews.cell("EstMainDat", index, `A${comp}`, row[`A${comp}`]),
+ number(result[`Contract${comp}`]),
+ EstViews.cell("EstMainDat", index, comp, row[comp]),
+ number(result[`D${comp}`]),
+ number(result[`E${comp}`]),
+ number(result[`F${comp}`]),
+ EstViews.cell("EstMainDat", index, "EscPlanned", row.EscPlanned || 0),
+ EstViews.cell("EstMainDat", index, "EscActual", row.EscActual || 0),
+ EstViews.cell("EstMainDat", index, "EscProgress", row.EscProgress || 0),
+ number(result.EscExcluded),
+ number(result.EscApplied),
+ ``,
+ EstViews.cell("EstMainDat", index, "EscManualQuantity", row.EscManualQuantity ?? 0),
+ EstViews.cell("EstMainDat", index, "EscConstruction", row.EscConstruction ?? 0),
+ EstViews.cell("EstMainDat", index, "EscPS", row.EscPS ?? 0),
+ EstViews.cell("EstMainDat", index, "EscBefore", row.EscBefore ?? 0),
+ ];
+ });
+ return box(
+ "물가변동 품목조정",
+ `` +
+ button("capture-bid", "현재 단가를 입찰단가로 기록") +
+ button("load-bid", "입찰단가 불러오기") +
+ button("load-contract", "계약 내역 불러오기") +
+ button("load-progress-exclusion", "기성 누계수량 불러오기") +
+ button(
+ "applied-style-toggle",
+ qty ? "적용대가 보기 변경(풀기)" : "적용대가 보기 변경",
+ qty ? 'aria-pressed="true"' : "",
+ ) +
+ button("applied-gn-load", "수량불러오기") +
+ button("es-wonga", "적용대가집계표") +
+ button("reset-escalation", "물가변동 입력 초기화") +
+ button("capture-baseline", "계약 내역 확정") +
+ button("escalation-prev", "이전", state.escalationPage === 0 ? "disabled" : "") +
+ `${state.escalationPage + 1} / ${page.pages}쪽 · ${page.total}행` +
+ button(
+ "escalation-next",
+ "다음",
+ state.escalationPage + 1 >= page.pages ? "disabled" : "",
+ ) +
+ ``,
+ table(
+ qty
+ ? ["코드", "품명", "계약 수량", ...QTY_LABELS, "적용제외", "적용수량"]
+ : [
+ "코드",
+ "품명",
+ "입찰 당시",
+ "계약 단가",
+ "변동 당시",
+ "등락률 %",
+ "등락폭",
+ "조정단가",
+ "예정공정수량",
+ "실행공정수량",
+ "기성수량",
+ "적용제외",
+ "적용수량",
+ "제외 자동",
+ "수동 제외수량",
+ "시공중 수량(참고)",
+ "PS 수량(참고)",
+ "선시공 수량(참고)",
+ ],
+ rows,
+ ),
+ `조사 도급액 ${number(output.designTotal)} · 계약 도급액 ${number(output.contractTotal)} · 조정 도급액 ${number(output.adjustedTotal)} · 적용제외(간접비 포함) ${number(output.excludedTotal)} · 적용대가 ${number(output.applied)} · 조정률 ${number(output.rate)}% · 조정금액 ${number(output.adjustment)}. 계약·입찰 파일은 동일 행 순서로 불러옵니다. ${esc(output.errors.join(" / "))}`,
+ );
+ }
+
+ // 수량불러오기 한 벌 적용: kind 0 = 계약 수량(계약 내역 확정본, 없으면 당초 칸 GN_), 1~6 = 구분 칸
+ function load(project, kind, source) {
+ const rows = project.tables.EstMainDat,
+ from = source.tables?.EstMainDat || [],
+ base = project.baseline?.tables?.EstMainDat;
+ let count = 0;
+ rows.forEach((row, i) => {
+ const src = from[i];
+ if (!src) return;
+ if (kind === 0) {
+ if (base?.[i]) base[i].GN = num(src.GN);
+ else row.GN_ = num(src.GN);
+ } else row[QTY[kind - 1]] = num(src.GN);
+ count++;
+ });
+ const wonga = project.tables.Wonga || [],
+ fromWonga = source.tables?.Wonga || [];
+ wonga.forEach((row, i) => fromWonga[i] && (row[COST[kind]] = num(fromWonga[i].Cost)));
+ return count;
+ }
+
+ function summary(project) {
+ const out = EstBusiness.escalation(project),
+ wonga = project.tables.Wonga || [];
+ return wonga.map((row, i) => {
+ const contract =
+ row.EsContractCost != null ? num(row.EsContractCost) : num(out.contractWonga?.[i]?.Cost),
+ except = num(out.excludedWonga?.[i]?.Cost);
+ return { row, contract, except, apply: contract - except };
+ });
+ }
+ function summaryDialog(state) {
+ const { esc, number } = EstApp,
+ list = summary(state.project);
+ if (!list.length) throw Error("원가계산서가 없습니다.");
+ EstApp.showDialog(
+ "적용대가집계표",
+ `| 비목 | 약어 | 계약금액 | 적용제외금액 | 적용대가금액 | 산출근거 |
${list
+ .map(
+ ({ row, contract, except, apply }) =>
+ `| ${esc([row.Bimog1, row.Bimog2, row.Name].filter(Boolean).join(" "))} | ${esc(row.SName ?? "")} | ${number(contract)} | ${number(except)} | ${number(apply)} | ${esc(row.VSusic ?? "")} |
`,
+ )
+ .join("")}
`,
+ );
+ }
+
+ async function gnLoad(state) {
+ const kind = await new Promise((resolve) =>
+ EstActions.form(
+ "수량불러오기",
+ [
+ {
+ name: "kind",
+ label: "불러올 수량분",
+ value: "",
+ choices: [["", "선택"], ...KINDS.map((k, i) => [i, k])],
+ },
+ ],
+ (values) => resolve(values.kind),
+ ),
+ );
+ if (kind === "" || kind == null) throw Error("불러올 수량분을 선택하세요..");
+ const file = await new Promise((resolve) => {
+ const picker = document.createElement("input");
+ picker.type = "file";
+ picker.accept = ".estx,.json";
+ picker.addEventListener("change", () => resolve(picker.files[0]));
+ picker.click();
+ });
+ if (!file) return;
+ const name = KINDS[Number(kind)];
+ let source;
+ try {
+ source = /\.estx$/i.test(file.name)
+ ? await EstApp.api(`/api/import?name=${encodeURIComponent(file.name)}`, {
+ method: "POST",
+ body: await file.arrayBuffer(),
+ })
+ : EstModel.legacy(JSON.parse(await file.text()));
+ } catch {
+ throw Error(`${name} 를 불러오지 못했습니다...`);
+ }
+ let count = 0;
+ EstApp.change((project) => (count = load(project, Number(kind), source)));
+ state.notice = `${name} ${count}행을 불러왔습니다.`;
+ EstApp.render();
+ }
+
+ function handle(name, state) {
+ if (name === "applied-style-toggle") {
+ state.escQtyView = !state.escQtyView;
+ EstApp.render();
+ } else if (name === "applied-gn-load")
+ gnLoad(state).catch((error) => {
+ state.notice = error.message;
+ EstApp.render();
+ });
+ else if (name === "es-wonga") summaryDialog(state);
+ else return false;
+ return true;
+ }
+ return { view, load, summary, handle, KINDS };
+})();
diff --git a/tools/est-test/native-business-actions.js b/tools/est-test/native-business-actions.js
index a2c660209..677b2cb56 100644
--- a/tools/est-test/native-business-actions.js
+++ b/tools/est-test/native-business-actions.js
@@ -639,7 +639,9 @@ const EstBusinessActions = (() => {
(typeof EstNewEstimate !== "undefined" && EstNewEstimate.handle(name, state)) ||
(typeof EstBook !== "undefined" && EstBook.handle(name, state, node)) ||
(typeof EstDataUpdate !== "undefined" && EstDataUpdate.handle(name, state)) ||
- (typeof EstAnnualLoad !== "undefined" && EstAnnualLoad.handle(name, state, node))
+ (typeof EstAnnualLoad !== "undefined" && EstAnnualLoad.handle(name, state, node)) ||
+ (typeof EstQuantityTable !== "undefined" && EstQuantityTable.handle(name, state, node)) ||
+ (typeof EstActionsMore !== "undefined" && EstActionsMore.handle(name, state, node))
) {
EstApp.render();
} else if (name === "insert-annual") {
diff --git a/tools/est-test/native-data-update.js b/tools/est-test/native-data-update.js
index 569eefa32..736d047e9 100644
--- a/tools/est-test/native-data-update.js
+++ b/tools/est-test/native-data-update.js
@@ -247,5 +247,5 @@ const EstDataUpdate = (() => {
else return false;
return true;
}
- return { apply, isFuel, handle };
+ return { apply, isFuel, table, handle };
})();
diff --git a/tools/est-test/native-doc-forms.js b/tools/est-test/native-doc-forms.js
index e9ca4ec72..4ab8ce78a 100644
--- a/tools/est-test/native-doc-forms.js
+++ b/tools/est-test/native-doc-forms.js
@@ -63,6 +63,7 @@ const EstDocForms = (() => {
["docf-default", "기본양식으로 저장"],
["docf-init", kind === "gabgi" ? "갑지초기화" : "표지초기화"],
["docf-print", "인쇄"],
+ ["docf-detail", "상세편집"],
]
.map(([a, l]) => ``)
.join("")}`;
@@ -108,6 +109,29 @@ const EstDocForms = (() => {
if (project.docs) delete project.docs[kind];
}, false);
EstDocs.handle("doc-edit", state, { dataset: { kind } });
+ } else if (name === "docf-detail") {
+ // 상세편집: 인쇄 모양을 자유 편집(원본은 미리보기 ↔ 편집 전환). 저장하면 인쇄가 이 모양을 씀, 「상세편집 해제」로 칸 값 모양으로
+ const doc = state.project.docs?.[kind] || {};
+ const html = doc.detailHtml || EstDocs.html(kind === "gabgi" ? 8 : 9, state.project);
+ EstApp.showDialog(
+ `${kind === "gabgi" ? "갑지" : "표지"} 상세편집`,
+ `${EstDocEditor.clean(html)}
`,
+ );
+ } else if (name === "docf-detail-save" || name === "docf-detail-clear") {
+ const html =
+ name === "docf-detail-save"
+ ? EstDocEditor.clean(document.getElementById("doc-detail").innerHTML)
+ : null;
+ EstApp.byId("dialog").close();
+ EstApp.change((project) => {
+ project.docs ||= {};
+ project.docs[kind] = { ...(project.docs[kind] || {}) };
+ if (html) project.docs[kind].detailHtml = html;
+ else delete project.docs[kind].detailHtml;
+ }, false);
+ state.notice = html
+ ? "상세편집 모양을 저장했습니다. 인쇄에 그대로 쓰입니다."
+ : "상세편집을 풀었습니다.";
} else if (name === "docf-print") {
EstApp.byId("dialog").close();
await EstReport.open();
@@ -117,6 +141,9 @@ const EstDocForms = (() => {
}
}
const names = new Set([
+ "docf-detail",
+ "docf-detail-save",
+ "docf-detail-clear",
"docf-list",
"docf-load",
"docf-delete",
diff --git a/tools/est-test/native-docs.js b/tools/est-test/native-docs.js
index 9c9d63d6d..716e1b07a 100644
--- a/tools/est-test/native-docs.js
+++ b/tools/est-test/native-docs.js
@@ -98,6 +98,10 @@ const EstDocs = (() => {
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c],
);
const align = (pos) => ["left", "center", "right"][Number(pos)] || "center";
+ // 상세편집(popupDetailEdit)으로 고친 모양이 있으면 그대로
+ const detail = { 8: "gabgi", 9: "cover" }[form];
+ if (detail && project.docs?.[detail]?.detailHtml && typeof EstDocEditor !== "undefined")
+ return `${EstDocEditor.clean(project.docs[detail].detailHtml)}
`;
if (form === 9) {
const c = doc(project, "cover");
return `${c.YearShow ? `
${esc(year(c.Date))}
` : ""}
${esc(c.Name)}
${c.Name2 ? `
${esc(c.Name2)}
` : ""}
${esc(c.CompanyName)}
`;
diff --git a/tools/est-test/native-excel-to-est.js b/tools/est-test/native-excel-to-est.js
new file mode 100644
index 000000000..17cecb3af
--- /dev/null
+++ b/tools/est-test/native-excel-to-est.js
@@ -0,0 +1,370 @@
+// 원본 「엑셀데이타를 EST로 변환」(mnuExcelToEst → TfrmExcelToEst btnOkClick 0x109c3bc — spec_pc §1) 7개 탭:
+// 노임 → 자재 → 견적 → 표준시장 → 단가산출 → 일위대가 → 내역서 차례로 한 Excel 파일의 시트들을 새 공사로 옮긴다(금액은 Excel 값 그대로,
+// 다시 셈 끔). 셀 규칙: 문자 Trim·줄바꿈 제거·품명/규격 앞 「.」 제거·단위 M2→㎡ M3→㎥·산출근거 「*」→「×」, 수치는 쉼표 빼고,
+// 노무비율 ≤ 1 이면 ×100, 열 「B:D」 는 이어 붙임. 단가산출·일위대가는 머리 줄 꼴(#.산근 · #| · 제n호표 · 산근 n 호표 : … (… 당) ·
+// (산근 · No n 호표 · No.n)을 찾아 호표를 만든다(원본의 글자 굵기 판정은 웹 Excel 읽기가 굵기를 몰라 하지 않음).
+const EstExcelToEst = (() => {
+ const TABS = [
+ ["LaborcDat", "노임단가", ["Code", "Name", "Size", "Unite", "Cost1", "Bigo"]],
+ [
+ "StuffcDat",
+ "자재단가",
+ [
+ "Code",
+ "Name",
+ "Size",
+ "Unite",
+ "Cost1",
+ "Page1",
+ "Cost2",
+ "Page2",
+ "Cost3",
+ "Page3",
+ "Cost4",
+ "Page4",
+ "Cost5",
+ "Page5",
+ "Cost6",
+ "Page6",
+ "TC",
+ "Bigo",
+ ],
+ ],
+ [
+ "EstiMateDat",
+ "견적단가",
+ ["Code", "Name", "Size", "GN", "Unite", "TC", "NC", "GC", "JC", "Bigo"],
+ ],
+ [
+ "SilJukDat",
+ "표준시장단가",
+ ["Code", "Name", "Size", "Unite", "TC", "NC", "GC", "JC", "NCRatio", "EXPLEAN", "Bigo"],
+ ],
+ ["CostMainDat", "단가산출", ["Code", "LineData", "TC", "NC", "GC", "JC"]],
+ [
+ "HeadMainDat",
+ "일위대가",
+ [
+ "Code",
+ "Name",
+ "Size",
+ "GN",
+ "Unite",
+ "TU",
+ "TC",
+ "NU",
+ "NC",
+ "GU",
+ "GC",
+ "JU",
+ "JC",
+ "Bigo",
+ ],
+ ],
+ [
+ "EstMainDat",
+ "내역서",
+ [
+ "Code",
+ "Pr",
+ "Name",
+ "Size",
+ "GN",
+ "Unite",
+ "TU",
+ "TC",
+ "NU",
+ "NC",
+ "GU",
+ "GC",
+ "JU",
+ "JC",
+ "Bigo",
+ ],
+ ],
+ ];
+ const TEXT = new Set([
+ "Code",
+ "Pr",
+ "Name",
+ "Size",
+ "Unite",
+ "Bigo",
+ "LineData",
+ "EXPLEAN",
+ "Page1",
+ "Page2",
+ "Page3",
+ "Page4",
+ "Page5",
+ "Page6",
+ ]);
+ const num = (v) => Number(v) || 0;
+ const letter = (n) => {
+ let s = "";
+ for (n += 1; n > 0; n = Math.floor((n - 1) / 26))
+ s = String.fromCharCode(65 + ((n - 1) % 26)) + s;
+ return s;
+ };
+ const col = (s) =>
+ [...String(s).toUpperCase()].reduce((n, c) => n * 26 + c.charCodeAt(0) - 64, 0) - 1;
+ const unit = (s) =>
+ String(s)
+ .replace(/^[Mm]2$/, "㎡")
+ .replace(/^[Mm]3$/, "㎥");
+
+ function cell(table, r, spec) {
+ const [a, b] = String(spec).toUpperCase().split(":");
+ if (!b) return table[r]?.[col(a)];
+ let out = "";
+ for (let c = col(a); c <= col(b); c++) {
+ const v = table[r]?.[c];
+ if (v != null && String(v) !== "") out += String(v);
+ else if (out.length <= 1) out += " ";
+ }
+ return out;
+ }
+ function value(field, raw) {
+ if (TEXT.has(field)) {
+ let t = String(raw ?? "").replace(/\r?\n/g, "");
+ t = field === "LineData" ? t.replaceAll("*", "×") : t.trim();
+ if (field === "Unite") t = unit(t);
+ if (field === "Name" || field === "Size") t = t.replace(/^\.+/, "");
+ return t;
+ }
+ let v = typeof raw === "number" ? raw : Number(String(raw ?? "").replaceAll(",", "")) || 0;
+ if (field === "NCRatio" && v <= 1) v *= 100;
+ return v;
+ }
+ function readRows(table, tab, fields, columns) {
+ const rows = [];
+ for (let r = tab.start - 1; r <= tab.end - 1; r++) {
+ const row = { Index: 0 };
+ fields.forEach((f, i) => {
+ if (columns[i]) row[f] = value(f, cell(table, r, columns[i]));
+ });
+ rows.push(row);
+ }
+ return rows;
+ }
+
+ // 머리 줄 꼴 → { Name, Size, Unite, skipNext }
+ function costHead(text, next) {
+ const t = String(text ?? "").trim(),
+ split = (s) => {
+ const parts = s.trim().split(/\s+/);
+ return {
+ Name: parts[0] || "",
+ Size: parts.slice(1, -1).join(" "),
+ Unite: parts.length > 1 ? parts.at(-1) : "",
+ };
+ };
+ if (/^#\./.test(t) && t.includes("산근")) {
+ if (t.includes("|")) {
+ const [, n = "", s = "", u = ""] = t.split("|");
+ return { Name: n.trim(), Size: s.trim(), Unite: u.trim() };
+ }
+ return split(t.replace(/^.*?산근\s*\d*/, "").replace(/\//g, " "));
+ }
+ if (/^#/.test(t) && t.includes("|")) {
+ const [, n = "", s = "", u = ""] = t.split("|");
+ return { Name: n.trim(), Size: s.trim(), Unite: u.trim() };
+ }
+ if (/산근.*호표/.test(t) && t.includes("당")) {
+ const body = t.split(/[::]/).slice(1).join(":"),
+ u = /\(\s*[\d.]*\s*([^()]*?)\s*당\s*\)/.exec(body)?.[1] || "",
+ rest = body
+ .replace(/\([^()]*당\s*\)/, "")
+ .trim()
+ .split(/\s+/);
+ return { Name: rest[0] || "", Size: rest.slice(1).join(" "), Unite: u };
+ }
+ if (/제\s*\d+\s*호표/.test(t) && next != null)
+ return { ...split(String(next)), skipNext: true };
+ if (t.replace(/\s/g, "").includes("(산근")) return split(t.replace(/\(산근[^)]*\)/, ""));
+ return null;
+ }
+ function headHead(name) {
+ const t = String(name ?? "").trim();
+ if (/No/.test(t) && t.includes("호표"))
+ return { Name: t.split("호표").slice(1).join("호표").trim(), Size: "", Unite: "" };
+ if (/^No\.\s*[1-9]/.test(t))
+ return { Name: t.replace(/^No\.\s*\d+\s*/, ""), Size: "", Unite: "" };
+ if (/\(호표/.test(t)) {
+ const parts = t
+ .replace(/\(호표[^)]*\)/, "")
+ .trim()
+ .split(/\s+/);
+ return { Name: parts[0] || "", Size: parts.slice(1).join(" "), Unite: "" };
+ }
+ return null;
+ }
+ function groups(rows, titles, kind, headOf) {
+ let pk = 0;
+ const out = [];
+ for (let i = 0; i < rows.length; i++) {
+ const row = rows[i];
+ if (kind === 103 && String(row.Name ?? "").replace(/\s/g, "") === "[합계]") continue;
+ const h = headOf(row, rows[i + 1]);
+ if (!h) {
+ out.push(row);
+ continue;
+ }
+ const index = titles.length + 1,
+ code = `N${String(++pk).padStart(10, "0")}`;
+ titles.push({
+ Index: index,
+ Code: code,
+ Name: h.Name,
+ Size: h.Size,
+ Unite: unit(h.Unite),
+ TC: num(row.TC),
+ NC: num(row.NC),
+ GC: num(row.GC),
+ JC: num(row.JC),
+ });
+ Object.assign(row, { Code: code, TitleCode: code, Index: index });
+ if (kind === 104)
+ row.LineData = `\x0e${h.Name}\x0e${h.Size ? `\x13${h.Size}\x13` : ""}${h.Unite ? `\x15${unit(h.Unite)}\x15` : ""}`;
+ out.push(row);
+ if (h.skipNext) i++;
+ }
+ return out;
+ }
+
+ function convert(sheets, tabs, template) {
+ const project = EstNative.empty();
+ project.name = template.name;
+ project.options = { ...project.options, AutoCalc: false, SourceRefresh: false };
+ project.tables.Wonga = structuredClone(template.wonga || []);
+ const failed = [];
+ for (const [table, label, fields] of TABS) {
+ const tab = tabs[table];
+ if (!tab?.sheet || !(tab.start > 0) || !(tab.end > 0)) continue;
+ const data = sheets[tab.sheet];
+ if (!data) {
+ failed.push(`${tab.sheet || label} 시트가 없습니다.`);
+ continue;
+ }
+ let rows = readRows(data, tab, fields, tab.columns);
+ if (table === "LaborcDat") rows.forEach((r) => (r.TC = r.Cost1));
+ if (table === "SilJukDat" || table === "EstiMateDat")
+ rows.forEach((r) =>
+ Object.assign(r, {
+ OTC: r.TC,
+ ONC: r.NC,
+ OGC: r.GC,
+ OJC: r.JC,
+ ...(table === "SilJukDat" && { Cost1: r.TC }),
+ }),
+ );
+ if (table === "CostMainDat") {
+ project.tables.CostTitleDat = [];
+ rows = groups(rows, project.tables.CostTitleDat, 104, (r, n) =>
+ costHead(r.LineData, n?.LineData),
+ );
+ } else if (table === "HeadMainDat") {
+ project.tables.HeadTitleDat = [];
+ rows = groups(rows, project.tables.HeadTitleDat, 103, (r) => headHead(r.Name));
+ }
+ project.tables[table] = rows;
+ }
+ while (project.tables.EstMainDat.length < 36) project.tables.EstMainDat.push({ Index: 0 });
+ let pk = 0;
+ for (const t of Object.keys(project.tables))
+ for (const r of project.tables[t])
+ if (r && typeof r === "object" && r.PkID == null) r.PkID = ++pk;
+ for (const t of ["LaborcDat", "StuffcDat", "SilJukDat", "EstiMateDat"])
+ for (const r of project.tables[t] || [])
+ if (!String(r.Code ?? "").trim()) r.Code = `N${String(r.PkID).padStart(10, "0")}`;
+ return { project, failed };
+ }
+
+ // 화면
+ function form(state) {
+ const x = (state.e2e ||= {
+ tab: "LaborcDat",
+ tabs: Object.fromEntries(
+ TABS.map(([t, label, fields]) => [
+ t,
+ { sheet: label, start: 0, end: 0, columns: fields.map((_, i) => letter(i)) },
+ ]),
+ ),
+ });
+ const { esc } = EstApp,
+ [, , fields] = TABS.find(([t]) => t === x.tab),
+ tab = x.tabs[x.tab];
+ EstApp.showDialog(
+ "엑셀데이타를 EST로 변환",
+ `${TABS.map(([t, l]) => ``).join("")}
${esc(x.file || "")} ${x.sheets ? `(시트 ${Object.keys(x.sheets).length}개)` : ""}
해당 칼럼명칭 입력(엑셀 열 글자, 비우면 건너뜀, 「B:D」 는 이어 붙임)
${fields.map((f, i) => ``).join("")}
`,
+ );
+ }
+ function read(state) {
+ const x = state.e2e,
+ tab = x.tabs[x.tab],
+ q = (n) => document.querySelector(`#dialog-content [name="${n}"]`);
+ if (!q("e2e-sheet")) return;
+ tab.sheet = q("e2e-sheet").value.trim();
+ tab.start = Number(q("e2e-start").value) || 0;
+ tab.end = Number(q("e2e-end").value) || 0;
+ document
+ .querySelectorAll('#dialog-content [name="e2e-col"]')
+ .forEach((n) => (tab.columns[Number(n.dataset.i)] = n.value.trim().toUpperCase()));
+ }
+ async function run(name, state, node) {
+ if (name === "excel-to-est") return form(state);
+ read(state);
+ const x = state.e2e;
+ if (name === "e2e-tab") x.tab = node.dataset.tab;
+ else if (name === "e2e-open") {
+ const file = await new Promise((resolve) => {
+ const picker = document.createElement("input");
+ picker.type = "file";
+ picker.accept = ".xlsx,.xls";
+ picker.addEventListener("change", () => resolve(picker.files[0]));
+ picker.click();
+ });
+ if (!file) return;
+ const body = await file.arrayBuffer();
+ x.sheets = /\.xls$/i.test(file.name)
+ ? EstJdlCompare.xlsTables(await EstApp.api("/api/read-xls", { method: "POST", body }))
+ : EstExcelMap.sheetTables(await EstApp.api("/api/import-xlsx", { method: "POST", body }));
+ x.file = file.name;
+ } else if (name === "e2e-run") {
+ const tabs = Object.values(x.tabs);
+ if (
+ tabs.every((t) => !t.start) ||
+ tabs.every((t) => !t.end) ||
+ tabs.some((t) => t.end < t.start)
+ )
+ throw Error("변환할 엑셀자료의 범위설정이 잘못되었습니다.");
+ if (!confirm("현재 작업을 엑셀 자료로 만든 새 공사로 바꾸시겠습니까?")) return;
+ const out = convert(x.sheets, x.tabs, {
+ name: x.file.replace(/\.xlsx?$/i, ""),
+ wonga: (state.project.tables.Wonga || []).map(({ Cost, ...rest }) => rest),
+ });
+ if (
+ out.failed.length &&
+ !confirm(`${out.failed.join("\n")}\n변환을 못한 시트가 있습니다.\n\n종료하시겠습니까?`)
+ )
+ return;
+ EstApp.byId("dialog").close();
+ EstApp.setProject(out.project);
+ state.notice =
+ "엑셀 역변환 작업이 완료되었습니다. 금액은 엑셀 값 그대로이며 자동계산은 꺼 두었습니다.";
+ EstApp.render();
+ return;
+ }
+ form(state);
+ }
+ const names = new Set(["excel-to-est", "e2e-tab", "e2e-open", "e2e-run"]);
+ function handle(name, state, node) {
+ if (!names.has(name)) return false;
+ run(name, state, node).catch((error) => {
+ state.notice = error.message;
+ EstApp.render();
+ });
+ return true;
+ }
+ return { convert, costHead, headHead, handle };
+})();
diff --git a/tools/est-test/native-excel-view.js b/tools/est-test/native-excel-view.js
index bd9b6e5cb..d93a91555 100644
--- a/tools/est-test/native-excel-view.js
+++ b/tools/est-test/native-excel-view.js
@@ -46,6 +46,11 @@ const EstExcelView = (() => {
btn("excel-style", "오른쪽", 'data-name="align" data-value="right"'),
btn("excel-merge", "병합"),
btn("excel-split", "분리"),
+ btn("excel-style", "테두리", 'data-name="border" data-value="1"'),
+ btn("excel-style", "테두리 없음", 'data-name="border" data-value=""'),
+ btn("excel-style", "음영", 'data-name="shade" data-value="#eeeeee"'),
+ btn("excel-style", "음영 없음", 'data-name="shade" data-value=""'),
+ btn("excel-mul-paste", "수량붙여넣기(연결)"),
``,
btn("excel-style", "왼쪽대각선", 'data-name="diag" data-value="left"'),
btn("excel-style", "오른쪽대각선", 'data-name="diag" data-value="right"'),
@@ -119,6 +124,8 @@ const EstExcelView = (() => {
s.u ? "text-decoration:underline" : "",
s.align ? `text-align:${s.align}` : "",
s.diag ? `background-image:${DIAG[s.diag]}` : "",
+ s.border ? "border:2px solid #222" : "",
+ s.shade && !link && !chosen ? `background-color:${s.shade}` : "",
link ? "background-color:#e6f4ea" : chosen ? "background-color:#e8f0fb" : "",
here && sel.row === r && sel.col === c ? "outline:2px solid #316ea5" : "",
]
@@ -236,6 +243,7 @@ const EstExcelView = (() => {
"excel-style",
"excel-merge",
"excel-split",
+ "excel-mul-paste",
"excel-print",
]);
function handle(name, state, node) {
@@ -286,7 +294,31 @@ const EstExcelView = (() => {
edit(() => EstExcelEdit.style(page, box, node.dataset.name, node.dataset.value));
else if (name === "excel-merge") edit(() => EstExcelEdit.merge(page, box));
else if (name === "excel-split") edit(() => EstExcelEdit.split(page, box));
- else if (name === "excel-print") {
+ else if (name === "excel-mul-paste") {
+ // 수량붙여넣기(연결)(actMulLangPaste 0xd31000): 수량 목록에서 담은 항목을 고른 셀부터 가로/세로로, 명칭·규격·단위·수량 중 하나
+ const clip = state.mulClip;
+ if (!clip?.items.length) throw Error("수량 목록에서 선택수량복사를 먼저 하세요.");
+ if (clip.code === sheet.Code)
+ throw Error("자기 자신의 데이타는 불러올수없습니다.셀수식을 사용하세요.");
+ const across = clip.items.length > 1 ? confirm("가로로 넣을까요? (취소 = 세로)") : true;
+ const kind =
+ clip.items.length > 1
+ ? Number(prompt("입력항목(0 명칭 · 1 규격 · 2 단위 · 3 수량)", "3"))
+ : 3;
+ if (![0, 1, 2, 3].includes(kind)) throw Error("입력항목은 0~3 입니다.");
+ edit(() => {
+ clip.items.forEach((item, k) => {
+ const r = box.r1 + (across ? 0 : k),
+ c = box.c1 + (across ? k : 0),
+ v = [item.Name, item.Size, item.Unite, Number(item.GN) || 0][kind];
+ page.cells[`${r},${c}`] = {
+ value: v ?? "",
+ ...(kind === 3 && { qlink: { sheet: clip.code, code: item.Code } }),
+ };
+ });
+ return EstExcelSheet.recalc(page.cells);
+ });
+ } else if (name === "excel-print") {
// 미리보기·인쇄(tbsPreView·tbsPrint): 고른 시트 격자만 새 창에
const html = render({ ...sheet, excel: [page], excelLinks: {} }, { excelCell: null })
.replace(/[\s\S]*?<\/div>/, "")
diff --git a/tools/est-test/native-extra.js b/tools/est-test/native-extra.js
index 30ede036e..d78e2ac23 100644
--- a/tools/est-test/native-extra.js
+++ b/tools/est-test/native-extra.js
@@ -126,184 +126,9 @@ const EstExtra = (() => {
: "현재 작업을 저장한 뒤 당초 내역을 확정하세요.",
);
} else if (view === "progress") {
- state.roundId ||= project.progress[0]?.id;
- const round = project.progress.find((r) => r.id === state.roundId);
- let body = '
회차를 추가하세요.
',
- tools =
- button("add-round", "기성 회차 추가") +
- '
' +
- (project.progress.length ? button("clear-progress", "기성 전체 초기화") : "");
- if (round) {
- tools +=
- `
` +
- button(
- round.locked ? "unlock-round" : "lock-round",
- round.locked ? "확정 해제(이후 포함)" : "회차 확정·저장",
- ) +
- button("fill-progress", "전량·비율 일괄 입력") +
- button("rename-round", "회차 이름") +
- button("delete-round", "회차 삭제") +
- button("reset-round-inputs", "회차 입력 초기화") +
- `
` +
- `
`;
- let output;
- try {
- output =
- round.locked && round.result ? round.result : EstBusiness.progress(project, round.id);
- } catch (e) {
- output = { rows: [], errors: [e.message] };
- }
- state.business = output;
- const page = EstBlockActions.progressPage(state, output.rows);
- tools +=
- button("progress-prev", "이전", state.progressPage === 0 ? "disabled" : "") +
- `
${state.progressPage + 1} / ${page.pages}쪽 · ${page.total}행` +
- button("progress-next", "다음", state.progressPage + 1 >= page.pages ? "disabled" : "");
- body =
- table(
- [
- "코드",
- "공종",
- "도급수량",
- "전회수량",
- "금회 입력",
- "입력방법",
- "사정 입력",
- "입력방법",
- "금회금액",
- "사정금액",
- "누계수량",
- "누계금액",
- ],
- page.rows.map(({ row, index }) => {
- const editable = ![54, 55, 58, 59, 62, 67].includes(Number(row.LinkIndex));
- const key = EstBusiness.rowKey(row, index),
- input = round.inputs?.[key] || {};
- const edit = (prefix) =>
- `
` +
- (input[prefix + "Mode"] === "components"
- ? EstMath.components
- .map(
- (field, i) =>
- `
`,
- )
- .join("")
- : "");
- const method = (prefix) =>
- !editable
- ? "자동계산"
- : `
`;
- return [
- esc(row.Code),
- esc(row.Name),
- number(row.GN),
- number(row.JunGN),
- edit("Gum"),
- method("Gum"),
- edit("Sa"),
- method("Sa"),
- number(row.GumTC),
- number(row.SaTC),
- number(row.TotalGN),
- number(row.TotalTC),
- ];
- }),
- ) +
- `
${esc((output.errors || []).join(" / "))} 금회 도급액 ${number(output.total)} · 기성비율 ${number(output.ratio)}%
`;
- body += EstProgressWonga.render(state, round, output);
- }
- html = box(
- "기성고",
- tools,
- body,
- "전회는 직전 확정 회차 누계를 읽습니다. 금회·사정은 수량·금액·비율 중 하나를 입력해 계산합니다.",
- );
+ html = EstProgressView.view(state, project, { box, button, table });
} else if (view === "escalation") {
- const output = EstBusiness.escalation(project);
- state.business = output;
- const comp = state.escalationComponent || "NU";
- const choices = EstMath.units
- .map(
- (key, i) =>
- `
`,
- )
- .join("");
- const page = EstBlockActions.escalationPage(state, project.tables.EstMainDat);
- const rows = page.rows.map(({ row, index }) => {
- const result = output.rows[index];
- return [
- esc(row.Code),
- esc(row.Name),
- EstViews.cell("EstMainDat", index, `A${comp}`, row[`A${comp}`]),
- number(result[`Contract${comp}`]),
- EstViews.cell("EstMainDat", index, comp, row[comp]),
- number(result[`D${comp}`]),
- number(result[`E${comp}`]),
- number(result[`F${comp}`]),
- EstViews.cell("EstMainDat", index, "EscPlanned", row.EscPlanned || 0),
- EstViews.cell("EstMainDat", index, "EscActual", row.EscActual || 0),
- EstViews.cell("EstMainDat", index, "EscProgress", row.EscProgress || 0),
- number(result.EscExcluded),
- number(result.EscApplied),
- `
`,
- EstViews.cell("EstMainDat", index, "EscManualQuantity", row.EscManualQuantity ?? 0),
- EstViews.cell("EstMainDat", index, "EscConstruction", row.EscConstruction ?? 0),
- EstViews.cell("EstMainDat", index, "EscPS", row.EscPS ?? 0),
- EstViews.cell("EstMainDat", index, "EscBefore", row.EscBefore ?? 0),
- ];
- });
- html = box(
- "물가변동 품목조정",
- `
` +
- button("capture-bid", "현재 단가를 입찰단가로 기록") +
- button("load-bid", "입찰단가 불러오기") +
- button("load-contract", "계약 내역 불러오기") +
- button("load-progress-exclusion", "기성 누계수량 불러오기") +
- button("reset-escalation", "물가변동 입력 초기화") +
- button("capture-baseline", "계약 내역 확정") +
- button("escalation-prev", "이전", state.escalationPage === 0 ? "disabled" : "") +
- `
${state.escalationPage + 1} / ${page.pages}쪽 · ${page.total}행` +
- button(
- "escalation-next",
- "다음",
- state.escalationPage + 1 >= page.pages ? "disabled" : "",
- ) +
- `
`,
- table(
- [
- "코드",
- "품명",
- "입찰 당시",
- "계약 단가",
- "변동 당시",
- "등락률 %",
- "등락폭",
- "조정단가",
- "예정공정수량",
- "실행공정수량",
- "기성수량",
- "적용제외",
- "적용수량",
- "제외 자동",
- "수동 제외수량",
- "시공중 수량(참고)",
- "PS 수량(참고)",
- "선시공 수량(참고)",
- ],
- rows,
- ),
- `조사 도급액 ${number(output.designTotal)} · 계약 도급액 ${number(output.contractTotal)} · 조정 도급액 ${number(output.adjustedTotal)} · 적용제외(간접비 포함) ${number(output.excludedTotal)} · 적용대가 ${number(output.applied)} · 조정률 ${number(output.rate)}% · 조정금액 ${number(output.adjustment)}. 계약·입찰 파일은 동일 행 순서로 불러옵니다. ${esc(output.errors.join(" / "))}`,
- );
+ html = EstAppliedCost.view(state, project, { box, button, table });
} else if (view === "annual") {
const output = EstBusiness.annual(project, false);
state.business = output;
diff --git a/tools/est-test/native-misc-tools.js b/tools/est-test/native-misc-tools.js
new file mode 100644
index 000000000..4c4c32c8f
--- /dev/null
+++ b/tools/est-test/native-misc-tools.js
@@ -0,0 +1,319 @@
+// 잡다한 원본 대화(spec_pb1·pb2): 전회기성 불러오기(TfrmAccomJunLoad — 완료 회차 아무것이나 전회로), 조달수수료 입력(TfrmJodalBiul, Ctrl+J),
+// 원가계산에 내역 금액 넣기(TfrmEstCostToWonga — 끌어 놓기 대신 줄 고르기), 원가 구성비 모드(popupBiulMode — 표시 전용 「비율」 열).
+const EstMiscTools = (() => {
+ const num = (v) => Number(v) || 0;
+ const plain = (s) => String(s ?? "").replace(/\s/g, "");
+
+ // 구성비(ca19d8·ca06c4): 기준 행(CalcBase, 없으면 도급액 줄) 대비 대상 비목 Cost 비율, 0.01% 절사 뒤 최대 나머지로 100% 맞춤
+ function biul(rows) {
+ let base = rows
+ .map((r, i) => (r.CalcBase ? i : -1))
+ .filter((i) => i >= 0)
+ .at(-1);
+ if (base == null)
+ base = rows.findIndex((r) => num(r.LinkIndex) === 55 || /도급액/.test(plain(r.Name)));
+ if (base < 0 || !num(rows[base].Cost)) return null;
+ const total = num(rows[base].Cost),
+ target = new Set();
+ let sum = 0;
+ rows.slice(0, base).forEach((r, i) => {
+ if (!num(r.Cost) || num(r.CalcIndex) >= 2) return;
+ const end = num(r.EndLineIndex);
+ if (i === 0 || end === 0 || end === 3 || (end === 1 && String(r.Youl ?? "").includes("%"))) {
+ target.add(i);
+ sum += num(r.Cost);
+ }
+ });
+ for (let i = base - 1; i >= 0 && sum < total; i--) {
+ const r = rows[i];
+ if (
+ num(r.Cost) &&
+ !target.has(i) &&
+ num(r.EndLineIndex) === 1 &&
+ !String(r.VSusic ?? "").trim()
+ ) {
+ target.add(i);
+ sum += num(r.Cost);
+ }
+ }
+ const exact = new Map([...target].map((i) => [i, num(rows[i].Cost) / total])),
+ cut = new Map([...exact].map(([i, v]) => [i, Math.trunc(v * 10000) / 10000]));
+ let t = [...cut.values()].reduce((a, b) => a + b, 0);
+ const bumped = new Set();
+ for (let n = Math.round((1 - t) * 10000); n > 0 && t < 1; n--) {
+ const pick = [...exact.keys()]
+ .filter((i) => !bumped.has(i))
+ .sort((a, b) => exact.get(b) - cut.get(b) - (exact.get(a) - cut.get(a)))[0];
+ if (pick == null) break;
+ cut.set(pick, cut.get(pick) + 0.0001);
+ bumped.add(pick);
+ t += 0.0001;
+ }
+ return rows.map((_, i) =>
+ i === base ? "100%" : cut.has(i) ? `${(cut.get(i) * 100).toFixed(2)}%` : "",
+ );
+ }
+
+ function jodal(state) {
+ if (state.view !== "EstMainDat") throw Error("내역서에서 쓰세요.");
+ const rows = state.project.tables.EstMainDat,
+ at = [...state.selection].sort((a, b) => a - b)[0],
+ row = rows[at];
+ if (!row) throw Error("조달수수료를 넣을 줄을 고르세요.");
+ if (
+ (num(row.Level) > 0 && plain(row.Name) && !plain(row.Name).includes("수수")) ||
+ ![0, 57, 64].includes(num(row.LinkIndex))
+ )
+ throw Error("현재 라인에는 조달수수료를 입력할수 없습니다.");
+ const edit = num(row.LinkIndex) === 57;
+ EstActions.form(
+ "조달수수료",
+ [
+ { name: "name", label: "명 칭", value: edit ? row.Name : "조달수수료" },
+ { name: "biul", label: "비 율(%)", type: "number", value: edit ? num(row.GN) : 0.54 },
+ {
+ name: "o",
+ label: "계산할 비목",
+ value: edit ? num(row.OCheck) || 1 : 1,
+ choices: [
+ [1, "재료비의"],
+ [2, "노무비의"],
+ [3, "경비의"],
+ [4, "합계의"],
+ ],
+ },
+ {
+ name: "s",
+ label: "계산결과 저장위치",
+ value: edit ? num(row.SCheck) || 1 : 1,
+ choices: [
+ [1, "재료비에"],
+ [3, "경비에"],
+ ],
+ },
+ {
+ name: "m",
+ label: "자리수 방법",
+ value: edit ? num(row.Index) || 2 : 2,
+ choices: [
+ [1, "사사오입"],
+ [2, "절하"],
+ [3, "절상"],
+ ],
+ },
+ { name: "d", label: "자리수", type: "number", value: edit ? num(row.Gangub) : 0 },
+ ],
+ (v) => {
+ if (!Number.isFinite(v.biul)) throw Error("비율은 수치로 입력하세요.");
+ EstApp.change((project) =>
+ Object.assign(project.tables.EstMainDat[at], {
+ Code: "AJJ01",
+ Name: String(v.name).trim(),
+ GN: v.biul,
+ Unite: "%",
+ LinkIndex: 57,
+ Index: Number(v.m),
+ Gangub: v.d,
+ OCheck: Number(v.o),
+ SCheck: Number(v.s),
+ }),
+ );
+ },
+ );
+ }
+
+ function costToWonga(state) {
+ if (state.view !== "Wonga") throw Error("원가계산서에서 넣을 줄을 고르세요.");
+ const at = [...state.selection].sort((a, b) => a - b)[0],
+ target = state.project.tables.Wonga[at];
+ if (!target) throw Error("원가계산서에서 넣을 줄을 고르세요.");
+ const est = state.project.tables.EstMainDat;
+ EstActions.form(
+ "원가계산에 입력할 비목선택",
+ [
+ {
+ name: "row",
+ label: "내역 줄",
+ value: "",
+ choices: est
+ .map((r, i) => [i, `${i + 1}. ${r.Name || r.Code || ""} ${num(r.TC).toLocaleString()}`])
+ .filter(([i]) => est[i].Name || est[i].Code),
+ },
+ {
+ name: "kind",
+ label: "입력할 비목",
+ value: -5,
+ choices: [
+ [-5, "합계 금액"],
+ [-1, "노무비 금액"],
+ [-2, "재료비 금액"],
+ [-3, "경비 금액"],
+ ],
+ },
+ ],
+ (v) => {
+ const src = est[Number(v.row)];
+ if (!src) throw Error("내역 줄을 고르세요.");
+ if (plain(target.Name) && !confirm(`${target.Name}에 덮어쓰시겠습니까?`)) return;
+ const field = { "-5": "TC", "-1": "NC", "-2": "GC", "-3": "JC" }[v.kind];
+ EstApp.change((project) =>
+ Object.assign(project.tables.Wonga[at], {
+ CalcIndex: 0,
+ LinkIndex: 1,
+ Youl: "",
+ VSusic: "",
+ BaseField: "",
+ Name: src.Name,
+ TC: num(src.TC),
+ NC: num(src.NC),
+ GC: num(src.GC),
+ JC: num(src.JC),
+ SC: num(src.SC),
+ OCheck: Number(v.kind),
+ Cost: num(src[field]),
+ }),
+ );
+ },
+ );
+ }
+
+ function junLoad(state) {
+ const project = state.project,
+ round = project.progress?.find((r) => r.id === state.roundId);
+ if (!round) throw Error("기성 회차를 고르세요.");
+ const done = project.progress.filter((r) => r.locked && r.result);
+ if (!done.length) throw Error("전회없슴");
+ EstActions.form(
+ "전회기성 불러오기",
+ [
+ {
+ name: "id",
+ label: "불러올 차수선택",
+ value: round.junFrom || "",
+ choices: done.map((r) => [r.id, `${r.name || r.id}`]),
+ },
+ ],
+ (v) => {
+ if (v.id === round.id) throw Error("현재작업중인 회차를 전회로 불러올수 없습니다.");
+ EstApp.change((p) => (p.progress.find((r) => r.id === round.id).junFrom = v.id), false);
+ },
+ );
+ }
+
+ // 인쇄양식 불러오기·저장하기(mnuPrintFormatLoad·Save 0x10ed318·0x10ed000): .pfot = 인쇄정보표 한 벌(원본 이진 그대로).
+ // 불러오면 공사 인쇄표(printCatalog)를 그 값으로, 저장은 지금 인쇄표(불러온 원본 행 우선)를 .pfot 로. .gfot(격자 배치)는 다루지 않음.
+ async function pfot(name, state) {
+ if (name === "pfot-load") {
+ const file = await new Promise((resolve) => {
+ const picker = document.createElement("input");
+ picker.type = "file";
+ picker.accept = ".pfot";
+ picker.addEventListener("change", () => resolve(picker.files[0]));
+ picker.click();
+ });
+ if (!file) return;
+ const rows = await EstApp.api("/api/pfot-read", {
+ method: "POST",
+ body: await file.arrayBuffer(),
+ });
+ EstApp.change((project) => {
+ project.printFormat = rows;
+ project.printCatalog = rows.map((r) =>
+ Object.fromEntries(Object.entries(r).filter(([k, v]) => k !== "__nulls" && !v?.binary)),
+ );
+ }, false);
+ state.notice = `인쇄양식 「${file.name}」(서식 ${rows.length}개)을 불러왔습니다.`;
+ } else {
+ const defaults = await EstApp.api("/api/print-catalog"),
+ saved = new Map((state.project.printCatalog || []).map((r) => [r.Index, r])),
+ full = new Map((state.project.printFormat || []).map((r) => [r.Index, r]));
+ const rows = defaults.map((r) => ({
+ ...r,
+ ...full.get(r.Index),
+ ...saved.get(r.Index),
+ __nulls: full.get(r.Index)?.__nulls,
+ }));
+ const response = await fetch("/api/pfot-write", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ rows }),
+ });
+ if (!response.ok) throw Error((await response.json()).error);
+ EstApp.download(
+ `${state.project.name || "인쇄양식"}.pfot`,
+ await response.blob(),
+ "application/octet-stream",
+ );
+ }
+ EstApp.render();
+ }
+
+ // 사용위치 검색 창(TfrmUsesDataSerch btnSerch·btnAllDisplay): 현재 표 항목을 품명·규격(빈칸·|·괄호 무시)으로 거르고 골라 사용위치 보기
+ function usesList(state, word) {
+ const rows = state.project.tables[state.view];
+ if (!rows) throw Error("자료 화면에서 쓰세요.");
+ const norm = (x) => String(x ?? "").replace(/[\s|()]/g, ""),
+ w = norm(word),
+ { esc } = EstApp;
+ const list = rows
+ .map((r, i) => [r, i])
+ .filter(
+ ([r]) => (r.Name || r.Code) && (!w || norm(r.Name).includes(w) || norm(r.Size).includes(w)),
+ )
+ .slice(0, 300);
+ EstApp.showDialog(
+ "데이터 사용위치 검색",
+ `
${list.map(([r, i]) => `| ${esc(r.Code ?? "")} | ${esc(r.Name ?? "")} | ${esc(r.Size ?? "")} | |
`).join("")}
`,
+ );
+ }
+
+ function handle(name, state, node) {
+ if (name === "uses-list" || name === "uses-list-all") {
+ usesList(state, "");
+ return true;
+ }
+ if (name === "uses-list-search") {
+ usesList(state, document.querySelector("[data-uses-word]")?.value || "");
+ return true;
+ }
+ if (name === "uses-list-pick") {
+ EstExtra.showUsesSearchDialog(state.project.tables[state.view][Number(node.dataset.i)]);
+ return true;
+ }
+ if (name === "pfot-load" || name === "pfot-save") {
+ pfot(name, state).catch((error) => {
+ state.notice = error.message;
+ EstApp.render();
+ });
+ return true;
+ }
+ if (name === "jodal-fee") jodal(state);
+ else if (name === "cost-to-wonga") costToWonga(state);
+ else if (name === "jun-load") junLoad(state);
+ else if (name === "wonga-biul-mode")
+ EstApp.change(
+ (project) => (project.options.WongaBiulMode = !project.options.WongaBiulMode),
+ false,
+ );
+ else return false;
+ return true;
+ }
+ if (typeof document !== "undefined" && document.addEventListener)
+ document.addEventListener("keydown", (event) => {
+ if (
+ event.ctrlKey &&
+ !event.altKey &&
+ event.key.toLowerCase() === "j" &&
+ EstApp.state.view === "EstMainDat"
+ ) {
+ event.preventDefault();
+ try {
+ jodal(EstApp.state);
+ } catch (error) {
+ EstApp.state.notice = error.message;
+ EstApp.render();
+ }
+ }
+ });
+ return { biul, handle };
+})();
diff --git a/tools/est-test/native-other-load.js b/tools/est-test/native-other-load.js
new file mode 100644
index 000000000..c1267e76f
--- /dev/null
+++ b/tools/est-test/native-other-load.js
@@ -0,0 +1,113 @@
+// 원본 「다른 공사 불러오기」 창(barbtnSelfLoad → f135b8 → TfrmOtherLoad — spec_pb2 §5.2·§11): 저장된 다른 작업의 같은 표를 띄워
+// 찾기(Ctrl+F: 품명·규격 빈칸·|·괄호 무시, 「A&B」 는 둘 다)·고른 줄을 하위 자료와 함께 이 공사로 가져오기(EstTransfer, 같은 코드는 이 공사 것).
+// 내역서는 고른 줄 아래에 줄을 넣고, 자재·노임·표준·견적·일위·단산·기계는 자료(호표)를 합친다.
+const EstOtherLoad = (() => {
+ const KIND = {
+ StuffcDat: 5,
+ LaborcDat: 6,
+ SilJukDat: 21,
+ EstiMateDat: 101,
+ HeadMainDat: 103,
+ CostMainDat: 104,
+ MachMainDat: 105,
+ };
+ const norm = (s) => String(s ?? "").replace(/[\s|()]/g, "");
+ const match = (row, word) => {
+ const k = norm(row.Name || EstText.clean(row.LineData || "")) + norm(row.Size);
+ return norm(word)
+ .split("&")
+ .filter(Boolean)
+ .every((p) => k.includes(p));
+ };
+
+ async function open(state) {
+ if (!state.project.tables[state.view]) throw Error("자료 화면에서 쓰세요.");
+ const o = (state.otherLoad ||= { id: "", word: "", picked: new Set() });
+ o.projects = await EstApp.api("/api/projects");
+ draw(state);
+ }
+ function rowsOf(o, view) {
+ const rows = o.source?.tables?.[view] || [];
+ return rows
+ .map((r, i) => [r, i])
+ .filter(([r]) => {
+ if (["HeadMainDat", "CostMainDat", "MachMainDat"].includes(view) && !(Number(r.Index) > 0))
+ return false;
+ return (r.Name || r.Code || r.LineData) && (!o.word || match(r, o.word));
+ });
+ }
+ function draw(state) {
+ const o = state.otherLoad,
+ { esc, number } = EstApp,
+ list = rowsOf(o, state.view).slice(0, 400);
+ EstApp.showDialog(
+ "다른 공사 불러오기",
+ `
`,
+ );
+ }
+ function take(state) {
+ const o = state.otherLoad,
+ view = state.view,
+ picked = [...o.picked].sort((a, b) => a - b).map((i) => o.source.tables[view][i]);
+ if (!picked.length) throw Error("가져올 줄을 고르세요.");
+ let added = 0,
+ kept = 0;
+ EstApp.change((project) => {
+ if (view === "EstMainDat") {
+ const deps = EstTransfer.collect(o.source, picked);
+ const r = EstTransfer.merge(project, deps);
+ added += r.added.length;
+ kept += r.kept.length;
+ const at = Math.max(-1, ...state.selection) + 1 || project.tables.EstMainDat.length;
+ added += EstTransfer.paste(project, picked, at);
+ } else {
+ const deps = EstTransfer.collect(
+ o.source,
+ picked.map((r) => ({ LinkIndex: KIND[view], Code: r.Code })),
+ );
+ const r = EstTransfer.merge(project, deps);
+ added += r.added.length;
+ kept += r.kept.length;
+ }
+ });
+ EstApp.byId("dialog").close();
+ state.notice = `${added}건을 가져왔습니다${kept ? `, 같은 코드 ${kept}건은 이 공사 자료를 그대로 씀` : ""}.`;
+ o.picked = new Set();
+ }
+
+ function install() {
+ document.addEventListener("change", async (event) => {
+ const t = event.target,
+ o = EstApp.state.otherLoad;
+ if (!o) return;
+ try {
+ if (t.dataset.olProject !== undefined) {
+ o.id = t.value;
+ o.source = t.value ? await EstApp.api(`/api/projects/${t.value}`) : null;
+ o.picked = new Set();
+ draw(EstApp.state);
+ } else if (t.dataset.olPick !== undefined) {
+ const i = Number(t.dataset.olPick);
+ if (t.checked) o.picked.add(i);
+ else o.picked.delete(i);
+ }
+ } catch (error) {
+ EstApp.state.notice = error.message;
+ EstApp.render();
+ }
+ });
+ }
+ if (typeof document !== "undefined" && document.addEventListener) install();
+
+ function handle(name, state) {
+ if (name === "other-load")
+ open(state).catch((e) => ((state.notice = e.message), EstApp.render()));
+ else if (name === "ol-search") {
+ state.otherLoad.word = document.querySelector("[data-ol-word]")?.value || "";
+ draw(state);
+ } else if (name === "ol-take") take(state);
+ else return false;
+ return true;
+ }
+ return { match, handle };
+})();
diff --git a/tools/est-test/native-price-tools.js b/tools/est-test/native-price-tools.js
new file mode 100644
index 000000000..c045102c5
--- /dev/null
+++ b/tools/est-test/native-price-tools.js
@@ -0,0 +1,211 @@
+// 단가 목록 도구(spec_pb2): 중복 항목 정리(TfrmStuffc popupSamecheck 0xf61d94), 단가정의 엑셀변환(TfrmSiljuk popupGunguToExcel),
+// 할증·낙찰·복원(TfrmSetDiget 모드 51·52, btnRestor 0x110919c), 바꾸기(barbtnChange → TfrmChangeValue: Main Data 로 이 표만 갱신),
+// SUM(barbtnSum → 항목선택계산 LinkIndex 62 줄, TfrmSetGabBiIndex SUM 모드).
+const EstPriceTools = (() => {
+ const num = (v) => Number(v) || 0;
+ const key = (s) => String(s ?? "").replace(/[ |]/g, "");
+ const TABLE_KIND = { StuffcDat: 5, LaborcDat: 6, SilJukDat: 21, EstiMateDat: 101 };
+
+ // 같은 품명·규격·단위(빈칸·| 무시) 묶음에서 TC 가장 큰 행 하나만(같으면 뒤 행) 남김. 묻지 않음, 다시 셈 없음.
+ function samecheck(rows) {
+ const keep = new Map();
+ rows.forEach((row, i) => {
+ const k = `${key(row.Name)}\u0000${key(row.Size)}\u0000${key(row.Unite)}`,
+ prev = keep.get(k);
+ if (prev == null || !(num(rows[prev].TC) > num(row.TC))) keep.set(k, i);
+ });
+ const kept = new Set(keep.values());
+ const removed = rows.length - kept.size;
+ return { rows: rows.filter((_, i) => kept.has(i)), removed };
+ }
+
+ // 표준시장단가 EXPLEAN 이 있는 행만 「코드 / 번호. 품명 (규격) [단위]」 + 설명 줄
+ function gungu(rows) {
+ const out = [];
+ for (const r of rows) {
+ const text = String(r.EXPLEAN ?? "").trim();
+ if (!text) continue;
+ out.push([
+ String(r.Code ?? ""),
+ `${r.Index ?? ""}. ${r.Name ?? ""} (${r.Size ?? ""}) [${r.Unite ?? ""}]`,
+ ]);
+ for (const line of text.split(/\r?\n/)) out.push(["", line]);
+ out.push(["", ""]);
+ }
+ return out;
+ }
+
+ // 할증(Ratio)·낙찰(NachalRatio) 율 쓰기. v = 100 이면 원래값(그 율 칸 100, 자리 해제), 복원 = 0.
+ function setRate(rows, indices, field, v) {
+ const list = indices.length >= 2 ? indices : rows.map((_, i) => i);
+ for (const i of list) {
+ const row = rows[i];
+ if (!num(row.TC) && !String(row.Name ?? "").trim()) continue;
+ if (!num(row.OTC)) row.OTC = row.TC;
+ for (const f of [field].flat()) row[f] = v;
+ if (v === 100) Object.assign(row, { Diget: 0, DigetIndex: 0 });
+ }
+ return list.length;
+ }
+
+ // 항목선택계산(62) 줄
+ function sumRow(view, row, o) {
+ const banned =
+ view === "EstMainDat"
+ ? [
+ 5, 6, 21, 51, 52, 53, 54, 56, 57, 58, 59, 61, 101, 103, 104, 105, 211, 212, 213, 214,
+ 215, 217, 218,
+ ]
+ : [5, 6, 21, 51, 52, 53, 61, 101, 103, 104, 105, 211, 212, 213, 214, 215, 217, 218];
+ if (
+ (view === "EstMainDat" && num(row.Level) > 0) ||
+ (view === "HeadMainDat" && num(row.Index) > 0) ||
+ (banned.includes(num(row.LinkIndex)) &&
+ (String(row.Name ?? "").trim() || String(row.Size ?? "").trim()))
+ )
+ throw Error("현재 라인에는 합계(SUM)를 할수가 없습니다.");
+ if ((o.base === 0) !== (o.target === 0)) throw Error("비목별항목은 비목별적용만 가능합니다.");
+ if (!o.picked.length) throw Error("더할항목의 색인들을 선택하세요");
+ if (o.picked.length > 5) throw Error("더할항목의 색인들은 6개이상 선택할수없습니다");
+ Object.assign(row, {
+ Name: o.name,
+ Size: o.size,
+ LinkIndex: 62,
+ Code: `A${o.picked.map((n) => String(n).padStart(2, "0")).join("")}`,
+ GN: o.rate,
+ Biul: o.rate / 100,
+ Unite: "%",
+ OCheck: o.base,
+ SCheck: o.target,
+ });
+ }
+ function nextSKind(rows, at, view) {
+ let start = at,
+ end = at;
+ const head = (r) => (view === "EstMainDat" ? num(r.Level) > 0 : num(r.Index) > 0);
+ while (start > 0 && !head(rows[start - 1])) start--;
+ while (end + 1 < rows.length && !head(rows[end + 1])) end++;
+ return Math.max(0, ...rows.slice(start, end + 1).map((r) => num(r.SKind))) + 1;
+ }
+
+ async function run(name, state) {
+ const view = state.view,
+ rows = state.project.tables[view],
+ picked = [...state.selection].filter((i) => rows?.[i]).sort((a, b) => a - b);
+ if (name === "price-samecheck") {
+ if (view !== "StuffcDat") throw Error("자재단가 화면에서 쓰세요.");
+ let removed = 0;
+ EstApp.change((project) => {
+ const out = samecheck(project.tables.StuffcDat);
+ project.tables.StuffcDat = out.rows;
+ removed = out.removed;
+ });
+ state.notice = `중복 항목 ${removed}개를 지웠습니다.`;
+ } else if (name === "price-gungu-excel") {
+ const list = gungu(state.project.tables.SilJukDat || []);
+ if (!list.length) throw Error("단가정의(설명)가 있는 표준시장단가가 없습니다.");
+ const response = await fetch("/api/export-report-xlsx", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ name: "단가정의",
+ columns: ["코드", "단가정의"],
+ rows: list,
+ errors: [],
+ }),
+ });
+ if (!response.ok) throw Error((await response.json()).error);
+ EstApp.download(
+ `${state.project.name || "공사"}_단가정의.xlsx`,
+ await response.blob(),
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ );
+ } else if (name === "price-ratio" || name === "price-nachal" || name === "price-restore") {
+ if (!TABLE_KIND[view]) throw Error("자재·노임·표준시장·견적 단가 화면에서 쓰세요.");
+ // 복원(원본은 할증·낙찰 창 안 단추): 두 율 모두 0
+ const field =
+ name === "price-nachal"
+ ? "NachalRatio"
+ : name === "price-ratio"
+ ? "Ratio"
+ : ["Ratio", "NachalRatio"];
+ let v = 0;
+ if (name !== "price-restore") {
+ const input = prompt(
+ name === "price-nachal" ? "낙찰율을 입력하세요(%)" : "할증율을 입력하세요(%)",
+ "",
+ );
+ if (input == null) return;
+ v = Number(input);
+ if (!Number.isFinite(v)) throw Error("율은 수치로 입력하세요.");
+ }
+ if (
+ !confirm(
+ v === 100 || v === 0 ? "원래값으로 복원하시겠습니까?" : "설정값을 적용하시겠습니까?",
+ )
+ )
+ return;
+ let n = 0;
+ EstApp.change((project) => (n = setRate(project.tables[view], picked, field, v)));
+ state.notice = `${n}개 행에 적용했습니다.`;
+ } else if (name === "price-change") {
+ const kind = TABLE_KIND[view];
+ if (!kind) throw Error("자재·노임·표준시장·견적 단가 화면에서 쓰세요.");
+ if (
+ !confirm(
+ "선택한 Main데이타의 단가로 바꾸시겠습니까?\n바꾸기후 전체재계산을 해야 금액이 바뀝니다.",
+ )
+ )
+ return;
+ const source = await EstApp.api("/api/main-data");
+ let missing = 0;
+ EstApp.change((project) => {
+ missing = EstDataUpdate.table(project.tables[view], source.tables[view] || [], kind);
+ });
+ state.notice = missing
+ ? `${missing} 개의 찾지못한 데이타가 있습니다. 확인란에 체크된 데이타를 검토하세요.`
+ : "Main 데이타의 단가로 바꿨습니다.";
+ } else if (name === "price-sum") {
+ if (!["EstMainDat", "HeadMainDat"].includes(view) || !picked.length)
+ throw Error("내역서·일위대가에서 SUM 줄로 바꿀 행을 고르세요.");
+ const row = rows[picked[0]];
+ const letters = prompt("더할 색인(A~Z, 최대 5개, 예 AC)", "")?.toUpperCase();
+ if (letters == null) return;
+ const rate = Number(prompt("적용율(%)", "100"));
+ const o = {
+ name: prompt("명칭", row.Name || "잡재료비") ?? "",
+ size: row.Size ?? "",
+ rate: Number.isFinite(rate) ? rate : 0,
+ base: num(row.OCheck),
+ target: num(row.SCheck),
+ picked: [...new Set(letters.replace(/[^A-Z]/g, ""))].map((c) => c.charCodeAt(0) - 64),
+ };
+ sumRow(view, structuredClone(row), o);
+ EstApp.change((project) => {
+ const list = project.tables[view];
+ sumRow(view, list[picked[0]], o);
+ list[picked[0]].SKind = nextSKind(list, picked[0], view);
+ });
+ } else return false;
+ EstApp.render();
+ return true;
+ }
+ const names = new Set([
+ "price-samecheck",
+ "price-gungu-excel",
+ "price-ratio",
+ "price-nachal",
+ "price-restore",
+ "price-change",
+ "price-sum",
+ ]);
+ function handle(name, state) {
+ if (!names.has(name)) return false;
+ run(name, state).catch((error) => {
+ state.notice = error.message;
+ EstApp.render();
+ });
+ return true;
+ }
+ return { samecheck, gungu, setRate, sumRow, handle };
+})();
diff --git a/tools/est-test/native-progress-view.js b/tools/est-test/native-progress-view.js
new file mode 100644
index 000000000..77973299b
--- /dev/null
+++ b/tools/est-test/native-progress-view.js
@@ -0,0 +1,333 @@
+// 기성고 화면(native-extra.js 에서 옮김)과 원본 기성 「환경설정」(TfrmAccomOption — spec_pb1 §4: 주 메뉴 환경 설정 0x10ccddc 가
+// 기성 문서일 때 · 기성 원가계산서 팝업 popupOptision 0xd479f4 Ctrl+U). 칸을 누르면 바로 옵션에 쓰고 「취소」면 연 때 값으로 되돌림(d45e1c).
+// 공통적용(코드·수량 0·금액 0·비목순서·품명정렬·품명 두줄·Enter 이동·바탕색)은 다른 표와 같은 계산 환경 Display,
+// 기성고(레벨·비고란 발췌근거·집계공종 진하게·사정금액·노재경 구분·실적 비목)는 AccomOption. 원본도 처리기가 없는
+// 자동저장 분·가로세로 계산 확인·가로 계산방법·재료비 처리·이윤 사사오입은 값만 담는다(화면·계산 불변).
+const EstProgressView = (() => {
+ const num = (v) => Number(v) || 0;
+ const COMMON = {
+ code: true,
+ gnZero: true,
+ costZero: true,
+ order: 0,
+ align: 0,
+ twoLine: false,
+ enter: 0,
+ color: "",
+ autoSave: false,
+ saveTime: 10,
+ };
+ const ACCOM = {
+ level: false,
+ gungu: false,
+ sumBold: false,
+ calcCheck: false,
+ horCalc: 0,
+ sajung: true,
+ gubun: false,
+ siljuk: false,
+ gcTo: 0,
+ eyunRound: false,
+ };
+ const get = (options) => ({
+ ...COMMON,
+ ...(options?.Display || {}),
+ ...ACCOM,
+ ...(options?.AccomOption || {}),
+ });
+ const ORDERS = ["TNGJ", "TGNJ", "NGJT", "GNJT"];
+ const PART = { T: "합계", N: "노무", G: "재료", J: "경비", S: "실적" };
+
+ // 비목 금액 열(금회): 비목순서대로, 실적은 실적 비목 표시 때만 경비 뒤에
+ function parts(o) {
+ const seq = [...(ORDERS[o.order] || ORDERS[0])];
+ if (o.siljuk) seq.splice(seq.indexOf("J") + 1, 0, "S");
+ return seq;
+ }
+
+ function view(state, project, { box, button, table }) {
+ const { esc } = EstApp,
+ o = get(project.options);
+ const shown = (v, zero) => (!zero && !num(v) ? "" : EstApp.number(v));
+ const qty = (v) => shown(v, o.gnZero),
+ cost = (v) => shown(v, o.costZero);
+ state.roundId ||= project.progress[0]?.id;
+ const round = project.progress.find((r) => r.id === state.roundId);
+ let body = '
회차를 추가하세요.
',
+ tools =
+ button("add-round", "기성 회차 추가") +
+ '
' +
+ (project.progress.length ? button("clear-progress", "기성 전체 초기화") : "") +
+ button("accom-option", "환경 설정");
+ if (round) {
+ tools +=
+ `
` +
+ button(
+ round.locked ? "unlock-round" : "lock-round",
+ round.locked ? "확정 해제(이후 포함)" : "회차 확정·저장",
+ ) +
+ button("fill-progress", "전량·비율 일괄 입력") +
+ button("rename-round", "회차 이름") +
+ button("delete-round", "회차 삭제") +
+ button("reset-round-inputs", "회차 입력 초기화") +
+ `
` +
+ `
`;
+ let output;
+ try {
+ output =
+ round.locked && round.result ? round.result : EstBusiness.progress(project, round.id);
+ } catch (e) {
+ output = { rows: [], errors: [e.message] };
+ }
+ state.business = output;
+ const page = EstBlockActions.progressPage(state, output.rows);
+ tools +=
+ button("progress-prev", "이전", state.progressPage === 0 ? "disabled" : "") +
+ `
${state.progressPage + 1} / ${page.pages}쪽 · ${page.total}행` +
+ button("progress-next", "다음", state.progressPage + 1 >= page.pages ? "disabled" : "");
+ const seq = o.gubun ? parts(o) : ["T"];
+ const nameStyle = [
+ ["", "text-align:right", "text-align:center"][o.align] || "",
+ o.twoLine ? "white-space:normal;max-width:16em" : "white-space:nowrap",
+ ]
+ .filter(Boolean)
+ .join(";");
+ const headers = [
+ ...(o.code ? ["코드"] : []),
+ ...(o.level ? ["레벨"] : []),
+ "공종",
+ "도급수량",
+ "전회수량",
+ "금회 입력",
+ "입력방법",
+ ...(o.sajung ? ["사정 입력", "입력방법"] : []),
+ ...seq.map((p) => (p === "T" ? "금회금액" : `금회 ${PART[p]}`)),
+ ...(o.sajung ? ["사정금액"] : []),
+ "누계수량",
+ "누계금액",
+ ...(o.gungu ? ["비고"] : []),
+ ];
+ body =
+ table(
+ headers,
+ page.rows.map(({ row, index }) => {
+ const editable = ![54, 55, 58, 59, 62, 67].includes(Number(row.LinkIndex));
+ const key = EstBusiness.rowKey(row, index),
+ input = round.inputs?.[key] || {};
+ const edit = (prefix) =>
+ `
` +
+ (input[prefix + "Mode"] === "components"
+ ? EstMath.components
+ .map(
+ (field, i) =>
+ `
`,
+ )
+ .join("")
+ : "");
+ const method = (prefix) =>
+ !editable
+ ? "자동계산"
+ : `
`;
+ const bold = o.sumBold && num(row.Level) > 0 ? ";font-weight:bold" : "";
+ return [
+ ...(o.code ? [esc(row.Code)] : []),
+ ...(o.level ? [num(row.Level) || ""] : []),
+ `
${esc(row.Name)}`,
+ qty(row.GN),
+ qty(row.JunGN),
+ edit("Gum"),
+ method("Gum"),
+ ...(o.sajung ? [edit("Sa"), method("Sa")] : []),
+ ...seq.map((p) => cost(row[`Gum${p}C`])),
+ ...(o.sajung ? [cost(row.SaTC)] : []),
+ qty(row.TotalGN),
+ cost(row.TotalTC),
+ ...(o.gungu ? [esc(row.Bigo ?? "")] : []),
+ ];
+ }),
+ ) +
+ `
${esc((output.errors || []).join(" / "))} 금회 도급액 ${EstApp.number(output.total)} · 기성비율 ${EstApp.number(output.ratio)}%
`;
+ body += EstProgressWonga.render(state, round, output);
+ }
+ const html = box(
+ "기성고",
+ tools,
+ body,
+ "전회는 직전 확정 회차 누계를 읽습니다. 금회·사정은 수량·금액·비율 중 하나를 입력해 계산합니다.",
+ );
+ return o.color ? `
${html}
` : html;
+ }
+
+ // 환경설정 창: 칸을 바꾸면 바로 옵션에 쓰고(화면에 즉시), 「취소」 는 연 때 값으로 되돌림
+ let opened = null;
+ function dialog(state) {
+ const { esc } = EstApp,
+ o = get(state.project.options);
+ opened = {
+ Display: structuredClone(state.project.options.Display),
+ AccomOption: structuredClone(state.project.options.AccomOption),
+ };
+ const check = (name, label, extra = "") =>
+ `
`;
+ const pick = (name, label, choices, extra = "") =>
+ `
`;
+ EstApp.showDialog(
+ "환경설정",
+ `
공통적용
${check("code", "코드표시")}${check("gnZero", '수량 "0" 표시')}${check("costZero", '금액 "0" 표시')}${check("autoSave", "데이타 자동저장")}${pick("order", "비목순서", ["합/노/재/경", "합/재/노/경", "노/재/경/합", "재/노/경/합"], o.gubun ? "" : "disabled")}${pick("align", "품명정렬", ["좌정렬", "우정렬", "중앙정렬"])}${pick("twoLine", "품명표기", ["한줄로 표시", "두줄로 표시"])}${pick("enter", "Enter 뒤 셀 이동", ["이동안함", "좌", "우", "위", "아래"])}
` +
+ `
기성고
${check("level", "레벨 표시")}${check("gungu", "비고란에 발췌근거 표시")}${check("sumBold", "집계공종 진하게 표시")}${check("calcCheck", "가로,세로 계산 확인")}${pick("horCalc", "데이타(가로) 계산방법", ["절하", "절상"])}${check("sajung", "사정금액 표시")}${check("gubun", "노,재,경 구분 표시")}${check("siljuk", "실적 비목 표시", o.gubun ? "" : "disabled")}
` +
+ `
원가계산
${pick("gcTo", "순직접공사비의 재료비 처리", ["간접재료비", "직접재료비"])}${check("eyunRound", "이윤 비율 사사오입")}
` +
+ `
`,
+ );
+ }
+ function set(name, value) {
+ const options = EstApp.state.project.options,
+ bucket = name in COMMON ? "Display" : "AccomOption";
+ const flag = typeof COMMON[name] === "boolean" || typeof ACCOM[name] === "boolean";
+ const typed = flag
+ ? typeof value === "string"
+ ? value === "1"
+ : Boolean(value)
+ : name === "color"
+ ? value
+ : num(value);
+ options[bucket] = { ...(options[bucket] || {}), [name]: typed };
+ const box = document.getElementById("dialog-content");
+ if (name === "autoSave") box.querySelector('[data-ao="saveTime"]').disabled = !typed;
+ if (name === "gubun") {
+ box.querySelector('[data-ao="order"]').disabled = !typed;
+ box.querySelector('[data-ao="siljuk"]').disabled = !typed;
+ }
+ EstApp.render();
+ }
+ function close(apply) {
+ const options = EstApp.state.project.options;
+ if (opened) {
+ const now = { Display: options.Display, AccomOption: options.AccomOption };
+ for (const k of ["Display", "AccomOption"])
+ if (opened[k] === undefined) delete options[k];
+ else options[k] = opened[k];
+ // 적용: 연 때 값으로 돌린 뒤 바뀐 값을 한 번에 기록(되돌리기 한 단계)
+ if (apply) EstApp.change((project) => Object.assign(project.options, now), false);
+ }
+ opened = null;
+ EstApp.byId("dialog").close();
+ EstApp.render();
+ }
+
+ // Enter 뒤 셀 이동(ComboBoxEnter 62c+0x60): 표 안 입력칸에서 Enter 면 좌·우·위·아래 칸으로
+ function move(input, direction) {
+ const cell = input.closest("td"),
+ tr = cell?.parentElement;
+ if (!tr) return null;
+ const col = [...tr.children].indexOf(cell);
+ let target = null;
+ if (direction === 1 || direction === 2) {
+ const cells = [...tr.children];
+ for (
+ let c = col + (direction === 1 ? -1 : 1);
+ c >= 0 && c < cells.length;
+ c += direction === 1 ? -1 : 1
+ )
+ if ((target = cells[c].querySelector("input:not([disabled]),select:not([disabled])")))
+ break;
+ } else if (direction === 3 || direction === 4) {
+ for (
+ let r = direction === 3 ? tr.previousElementSibling : tr.nextElementSibling;
+ r;
+ r = direction === 3 ? r.previousElementSibling : r.nextElementSibling
+ )
+ if (
+ (target = r.children[col]?.querySelector("input:not([disabled]),select:not([disabled])"))
+ )
+ break;
+ }
+ return target;
+ }
+ function install() {
+ document.addEventListener("change", (event) => {
+ const t = event.target;
+ if (t.dataset?.ao === undefined) return;
+ set(t.dataset.ao, t.type === "checkbox" ? t.checked : t.value);
+ });
+ document.addEventListener("keydown", (event) => {
+ const state = EstApp.state;
+ if (state?.view !== "progress") return;
+ if (event.ctrlKey && event.key.toLowerCase() === "u") {
+ event.preventDefault();
+ dialog(state);
+ return;
+ }
+ const t = event.target;
+ if (event.key !== "Enter" || !t.matches?.("table.sheet input, table.sheet select")) return;
+ const direction = num(get(state.project.options).enter);
+ if (!direction) return;
+ const target = move(t, direction);
+ if (target) {
+ event.preventDefault();
+ t.dispatchEvent(new Event("change", { bubbles: true }));
+ setTimeout(() =>
+ (
+ document.querySelector(
+ `[data-key="${CSS.escape(target.dataset.key || "")}"][data-progress="${target.dataset.progress}"]`,
+ ) || target
+ ).focus(),
+ );
+ }
+ });
+ }
+ if (typeof document !== "undefined" && document.addEventListener) install();
+
+ // 기성 회차 추가: 설계변경 내역서면 「도급내역을 선택하세요.」(당초/변경 — 닫기 없음, 고른 값으로 회차를 만듦)
+ function addRound(state) {
+ const project = state.project,
+ push = (loadMethod) => {
+ const next = {
+ id: EstApp.uid(),
+ name: EstBusiness.newProgressName(project.progress),
+ inputs: {},
+ approved: false,
+ locked: false,
+ ...(loadMethod && { loadMethod }),
+ };
+ EstApp.change(() => {
+ project.progress.push(next);
+ state.roundId = next.id;
+ }, false);
+ };
+ if (project.options.Mode !== "설계변경") return push();
+ EstActions.form(
+ "도급내역을 선택하세요.",
+ [
+ {
+ name: "method",
+ label: "설계변경한 내역서입니다. 도급내역으로 불러올 데이타를 선택하세요.",
+ value: "origin",
+ choices: [
+ ["origin", "당초 내역서"],
+ ["change", "변경 내역서"],
+ ],
+ },
+ ],
+ (values) => push(values.method),
+ );
+ }
+
+ function handle(name, state) {
+ if (name === "accom-option") dialog(state);
+ else if (name === "accom-option-ok") close(true);
+ else if (name === "accom-option-cancel") close(false);
+ else return false;
+ return true;
+ }
+ return { view, get, parts, move, handle, dialog, addRound };
+})();
diff --git a/tools/est-test/native-progress-wonga.js b/tools/est-test/native-progress-wonga.js
index a1e3b2aa6..dc242ba53 100644
--- a/tools/est-test/native-progress-wonga.js
+++ b/tools/est-test/native-progress-wonga.js
@@ -119,6 +119,7 @@ const EstProgressWonga = (() => {
b("pw-total", "공사비역산 자리수 맞추기"),
b("pw-init", "원가계산서 초기화"),
b("pw-undo", "되살리기"),
+ '
',
`
`,
round.wonga ? "
회차 서식 사용 중" : "",
round.wongaTotalDigits ? `
역산 ${round.wongaTotalDigits}자리` : "",
diff --git a/tools/est-test/native-progress.js b/tools/est-test/native-progress.js
index 9ff15966f..4a763a262 100644
--- a/tools/est-test/native-progress.js
+++ b/tools/est-test/native-progress.js
@@ -26,11 +26,28 @@ const EstProgress = (() => {
}
return output;
}
+ // 도급내역 선택(TfrmAccomLoadMethodEst 0xc8cd50 · 덮어쓰기 c8e5e8): 설계변경 내역서로 회차를 처음 만들 때 당초를 고르면
+ // 행마다 당초 칸(_)으로 — Index, 품명·규격·단위(당초가 비면 지금 값), 수량·단가·금액. 원본은 GC 를 빠뜨리지만 웹은 함께 옮김.
+ function originRows(rows) {
+ return rows.map((row) => {
+ const out = { ...row };
+ if (row.Index_ != null) out.Index = row.Index_;
+ for (const k of ["Name", "Size", "Unite"])
+ if (String(row[k + "_"] ?? "").trim()) out[k] = row[k + "_"];
+ for (const k of ["GN", "TU", "TC", ...units, ...components])
+ if (row[k + "_"] != null) out[k] = row[k + "_"];
+ return out;
+ });
+ }
+ const contractRows = (project, round) =>
+ round.loadMethod === "origin"
+ ? originRows(project.tables.EstMainDat)
+ : project.tables.EstMainDat;
function calculate(project, roundId) {
const round = project.progress.find((item) => item.id === roundId);
if (!round) throw Error("기성 회차 없음");
const source = round.contract || {
- rows: project.tables.EstMainDat,
+ rows: contractRows(project, round),
wonga: project.tables.Wonga,
options: project.options,
};
@@ -42,12 +59,17 @@ const EstProgress = (() => {
const ambiguous = ambiguousKeys(rows);
const earlier = project.progress.slice(0, position);
if (earlier.some((item) => !item.locked)) errors.push("전회 기성 회차를 먼저 확정하세요.");
- const previous = earlier.at(-1)?.result,
+ // 전회기성 불러오기(TfrmAccomJunLoad): 고른 완료 회차가 있으면 그 누계를 전회로
+ const chosen =
+ round.junFrom &&
+ project.progress.find((r) => r.id === round.junFrom && r.locked && r !== round),
+ prior = chosen || earlier.at(-1);
+ const previous = prior?.result,
oldRows = new Map((previous?.rows || []).map((row, index) => [key(row, index), row]));
- if (earlier.at(-1)?.locked && !Array.isArray(previous?.rows))
+ if (prior?.locked && !Array.isArray(previous?.rows))
errors.push("확정된 전회 기성 결과가 없습니다. 전회 결과를 확인하세요.");
if (
- earlier.at(-1)?.locked &&
+ prior?.locked &&
source.wonga?.length &&
!Array.isArray(previous?.costs) &&
!Array.isArray(previous?.wonga)
@@ -311,7 +333,7 @@ const EstProgress = (() => {
if (!round) throw Error("기성 회차 없음");
if (round.locked) throw Error("이미 확정한 회차입니다.");
round.contract = {
- rows: structuredClone(project.tables.EstMainDat),
+ rows: structuredClone(contractRows(project, round)),
wonga: structuredClone(project.tables.Wonga),
options: { ...project.options },
};
@@ -341,5 +363,5 @@ const EstProgress = (() => {
}
return project;
}
- return { calculate, lock, unlock, rebuild, ambiguousKeys };
+ return { calculate, lock, unlock, rebuild, ambiguousKeys, originRows };
})();
diff --git a/tools/est-test/native-quantity-list.js b/tools/est-test/native-quantity-list.js
new file mode 100644
index 000000000..0b776eeb4
--- /dev/null
+++ b/tools/est-test/native-quantity-list.js
@@ -0,0 +1,157 @@
+// 원본 수량 목록(TFRMMULLANGLIST — spec_pa §2·3)·수량붙여넣기(연결)(TFRMEST ActionMullangInput §8.2)·
+// 품명·규격 지정(TFRMMULNAMEINPUT btnSetName·btnSetSize §4)·공종명칭 입력(TFRMMULTITLENAMEINPUT §5).
+// 수량 목록: 왼쪽 모든 산출서(검색 = 명칭·규격에서 빈칸·|·괄호 뺀 부분일치, 전체보기) · 가운데 고른 산출서 수량 항목 · 오른쪽 원문.
+// 선택수량복사: 고른 수량 항목을 앱 안에 담아 둠(클립보드 아님). 내역서 「수량붙여넣기(연결)」: 고른 줄부터 아래로 차례로
+// 수량을 넣고 그 항목을 내역 줄에 연결(「품명,규격입력」 체크면 품명·규격·단위도). 행은 더하지 않음.
+const EstQuantityList = (() => {
+ const norm = (s) => String(s ?? "").replace(/[\s|()]/g, "");
+ const RESULT_NAMES = { name: ["\x0e", "품명"], size: ["\x13", "규격"] };
+
+ function open(state) {
+ const l = (state.qList ||= { word: "", code: null, nameSize: false, picked: new Set() });
+ const { esc, number } = EstApp,
+ sheets = (state.project.quantitySheets || []).filter(
+ (s) => !l.word || norm(s.Name).includes(l.word) || norm(s.Size).includes(l.word),
+ ),
+ sheet = state.project.quantitySheets?.find((s) => s.Code === l.code) || sheets[0];
+ l.code = sheet?.Code;
+ const items = sheet ? EstQuantity.calculate(sheet).items : [];
+ const preview = sheet
+ ? sheet.rows
+ .map((r) =>
+ r.table
+ ? `
${r.table.map((tr) => `${tr.map((c) => `| ${esc(c.text)} | `).join("")}
`).join("")}
`
+ : `
${esc(EstText.clean(r.LineData || ""))}
`,
+ )
+ .join("")
+ : "";
+ EstApp.showDialog(
+ "수량 목록",
+ `
${sheets.map((s) => `
`).join("")}
${preview}
`,
+ );
+ }
+
+ function paste(state) {
+ const clip = state.mulClip;
+ if (!clip?.items.length) throw Error("수량 목록에서 선택수량복사를 먼저 하세요.");
+ if (state.view !== "EstMainDat") throw Error("내역서에서 붙여넣을 줄을 고르세요.");
+ const rows = state.project.tables.EstMainDat,
+ start = [...state.selection].sort((a, b) => a - b)[0];
+ if (!Number.isInteger(start)) throw Error("내역서에서 붙여넣을 줄을 고르세요.");
+ EstApp.change((project) => {
+ const sheet = project.quantitySheets.find((s) => s.Code === clip.code);
+ clip.items.forEach((src, k) => {
+ const row = project.tables.EstMainDat[Math.min(start + k, rows.length - 1)],
+ item = sheet?.items.find((i) => i.Code === src.Code);
+ if (clip.nameSize) Object.assign(row, { Name: src.Name, Size: src.Size, Unite: src.Unite });
+ row.GN = Number(src.GN) || 0;
+ row.MullangIndex = 9999;
+ if (item && row.PkID != null)
+ Object.assign(item, { TargetKey: `id:${row.PkID}`, TargetCode: row.Code || "" });
+ });
+ });
+ state.notice = `수량 ${clip.items.length}개를 붙여넣고 연결했습니다.`;
+ }
+
+ // 품명·규격 지정: 산출 줄 글에서 고른 글자를 ⟦품명⟧·⟦규격⟧ 표지로(한 줄에 하나, 40자 이하, 제목 표지 위 금지)
+ function nameSize(state, kind, index) {
+ const sheet = state.project.quantitySheets?.find((s) => s.Code === state.quantitySheet),
+ editor = document.querySelector(`[data-quantity-line="${index}"]`);
+ if (!sheet?.rows[index] || !editor) throw Error("산출 줄을 고르세요.");
+ const [mark, label] = RESULT_NAMES[kind],
+ text = editor.value;
+ let a = editor.selectionStart ?? 0,
+ b = editor.selectionEnd ?? a;
+ if (a === b) {
+ while (a > 0 && /[^\s⟦⟧]/.test(text[a - 1])) a--;
+ while (b < text.length && /[^\s⟦⟧]/.test(text[b])) b++;
+ }
+ const picked = text.slice(a, b);
+ if (EstText.encode(text).includes(mark))
+ throw Error(`한라인에 ${label}을두번이상 지정할수 없습니다.`);
+ if (sheet.rows[index].title) throw Error("제목표시에는 지정할수 없습니다.");
+ if (picked.length > 40) throw Error("선택한 범위가 너무 큽니다.");
+ if (!picked.trim())
+ throw Error(
+ `${label}으로 지정할 문자를 드래그하여 선택하던지커서를 선택할위치로 옮겨 놓으세요.`,
+ );
+ const token = `⟦${label}⟧`;
+ EstApp.change(() => {
+ sheet.rows[index].LineData = EstText.encode(
+ text.slice(0, a) + token + picked + token + text.slice(b),
+ );
+ sheet.markerEdited = true;
+ }, false);
+ }
+
+ // 공종명칭 입력: 목록 명칭·규격을 바꾸고 문서 머리에 「공백5 + 명칭(규격) + 공백5」 제목 줄(굵게 큰 글자)을 넣거나 바꿈
+ function title(state) {
+ const sheet = state.project.quantitySheets?.find((s) => s.Code === state.quantitySheet);
+ if (!sheet) throw Error("수량산출서를 고르세요.");
+ const head = sheet.rows[0]?.title ? sheet.rows[0] : null;
+ const name = prompt("명칭", sheet.Name ?? "");
+ if (name == null) return;
+ const size = prompt("규격", sheet.Size ?? "") ?? "";
+ EstApp.change(() => {
+ sheet.Name = name.trim();
+ sheet.Size = size.trim();
+ const text = ` ${name.trim()}${size.trim() ? `(${size.trim()})` : ""} `;
+ if (head) head.LineData = text;
+ else
+ sheet.rows.unshift({ LineData: text, markers: [], title: true, para: { align: "center" } });
+ sheet.tableEdited = true;
+ }, false);
+ }
+
+ function install() {
+ document.addEventListener("click", (event) => {
+ const a = event.target.closest("[data-ql-sheet]");
+ if (!a) return;
+ event.preventDefault();
+ const l = EstApp.state.qList;
+ l.code = a.dataset.qlSheet;
+ l.picked = new Set();
+ open(EstApp.state);
+ });
+ document.addEventListener("change", (event) => {
+ const t = event.target,
+ l = EstApp.state.qList;
+ if (!l) return;
+ if (t.dataset.qlItem !== undefined) {
+ const k = Number(t.dataset.qlItem);
+ if (t.checked) l.picked.add(k);
+ else l.picked.delete(k);
+ } else if (t.dataset.qlNamesize !== undefined) l.nameSize = t.checked;
+ else if (t.dataset.qlWord !== undefined) {
+ l.word = norm(t.value);
+ open(EstApp.state);
+ }
+ });
+ }
+ if (typeof document !== "undefined" && document.addEventListener) install();
+
+ function handle(name, state, node) {
+ if (name === "quantity-list") open(state);
+ else if (name === "ql-search") {
+ state.qList.word = norm(document.querySelector("[data-ql-word]")?.value);
+ open(state);
+ } else if (name === "ql-all") {
+ state.qList.word = "";
+ open(state);
+ } else if (name === "ql-copy") {
+ const l = state.qList,
+ sheet = state.project.quantitySheets.find((s) => s.Code === l.code),
+ items = EstQuantity.calculate(sheet).items.filter((_, k) => l.picked.has(k));
+ if (!items.length) throw Error("복사할 수량 항목을 고르세요.");
+ state.mulClip = { code: sheet.Code, items, nameSize: l.nameSize };
+ state.notice = `수량 ${items.length}개를 담았습니다. 내역서에서 수량붙여넣기(연결)를 누르세요.`;
+ EstApp.render();
+ } else if (name === "mul-paste") paste(state);
+ else if (name === "quantity-name" || name === "quantity-size")
+ nameSize(state, name === "quantity-name" ? "name" : "size", Number(node.dataset.index));
+ else if (name === "quantity-title") title(state);
+ else return false;
+ return true;
+ }
+ return { handle };
+})();
diff --git a/tools/est-test/native-quantity-sheets.js b/tools/est-test/native-quantity-sheets.js
index 0e07d6441..364b6847f 100644
--- a/tools/est-test/native-quantity-sheets.js
+++ b/tools/est-test/native-quantity-sheets.js
@@ -129,7 +129,7 @@ const EstQuantitySheets = (() => {
`
`,
)
.join("");
- return `
`;
+ return `
`;
}
function handle(name, state, node) {
@@ -274,7 +274,7 @@ const EstQuantitySheets = (() => {
function excelView(sheet) {
if (typeof EstExcelView === "undefined") return "";
const on = sheet.excel?.length && !sheet.excelOff;
- return `
${on ? EstExcelView.render(sheet, EstApp.state) : ""}`;
+ return `${typeof EstQuantityTable !== "undefined" ? EstQuantityTable.banner(sheet) : ""}
${on ? EstExcelView.render(sheet, EstApp.state) : ""}`;
}
function excelMode(state) {
const sheet = state.project.quantitySheets?.find((s) => s.Code === state.quantitySheet);
diff --git a/tools/est-test/native-quantity-table.js b/tools/est-test/native-quantity-table.js
new file mode 100644
index 000000000..95e69b69d
--- /dev/null
+++ b/tools/est-test/native-quantity-table.js
@@ -0,0 +1,320 @@
+// 워드형 수량산출서 표 화면·편집 — 칸 고르기(Shift 로 범위), 칸 글·수식(=…) 고치기, 방향 셀 합(ActionAddRight·Left·Up·Down),
+// 수량으로 지정·지정 해제(칸 수량 표지 → 수량 항목), 셀수직병합·셀분리(btnCellMergeVer·btnCellSplitV), 표 만들기(mnuTableCreate).
+// 계산·표 자료는 native-quantity-wpt.cjs. 표를 고친 산출서는 원본 WPT 로 다시 쓰지 못하므로 ESTX 저장을 막는다(tableEdited).
+const EstQuantityTable = (() => {
+ const W = () => EstQuantityWpt;
+ function sheetOf(state) {
+ return state.project.quantitySheets?.find((s) => s.Code === state.quantitySheet);
+ }
+ const PAGE = 45; // 웹 산출서 한 쪽 줄 수(원본은 편집기 쪽 배치)
+ function paraStyle(row) {
+ const p = row.para || {};
+ return [
+ p.align && `text-align:${p.align}`,
+ p.indent && `padding-left:${p.indent * 8}px`,
+ row.title && "font-weight:bold;font-size:20px;color:#800000",
+ p.bold && "font-weight:bold",
+ p.size && `font-size:${p.size}px`,
+ p.color && `color:${p.color}`,
+ ]
+ .filter(Boolean)
+ .join(";");
+ }
+ // 테두리선(popFormatOutLine)·산출근거 양식(popFormatGungu): 화면에만 그림(원본도 글로 넣지 않음)
+ function banner(sheet) {
+ return sheet.gunguForm
+ ? `
`
+ : sheet.outline
+ ? '
테두리선 켬(인쇄 쪽 둘레)
'
+ : "";
+ }
+ function html(sheet, index, state) {
+ const { esc } = EstApp;
+ if (sheet.rows[index].image)
+ return `
})
`;
+ const table = sheet.rows[index].table,
+ sel = state.qtCell?.sheet === sheet.Code && state.qtCell.row === index ? state.qtCell : null;
+ W().recalc(table);
+ const inBox = (r, c) =>
+ sel &&
+ r >= Math.min(sel.r, sel.r2 ?? sel.r) &&
+ r <= Math.max(sel.r, sel.r2 ?? sel.r) &&
+ c >= Math.min(sel.c, sel.c2 ?? sel.c) &&
+ c <= Math.max(sel.c, sel.c2 ?? sel.c);
+ const cur = sel ? table[sel.r]?.[sel.c] : null;
+ const tools = sel
+ ? `
${W().letters(sel.c)}${sel.r + 1} ${[
+ ["right", "우측 합"],
+ ["left", "좌측 합"],
+ ["up", "위 합"],
+ ["down", "아래 합"],
+ ]
+ .map(([d, l]) => ``)
+ .join(
+ "",
+ )}
`
+ : "";
+ const body = table
+ .map(
+ (row, r) =>
+ `
${row
+ .map((cell, c) =>
+ cell.vmerge
+ ? ""
+ : `| ${esc(cell.text) || " "} | `,
+ )
+ .join("")}
`,
+ )
+ .join("");
+ const border = sheet.rows[index].tableBorder ?? 1;
+ return `${tools}
`;
+ }
+
+ function edit(state, fn) {
+ const sheet = sheetOf(state),
+ sel = state.qtCell;
+ if (!sheet || !sel || sel.sheet !== sheet.Code) throw Error("표에서 칸을 고르세요.");
+ const table = sheet.rows[sel.row]?.table;
+ if (!table) throw Error("표에서 칸을 고르세요.");
+ EstApp.change(() => {
+ fn(table, sel, sheet);
+ W().recalc(table);
+ sheet.tableEdited = true;
+ }, false);
+ }
+
+ function handle(name, state, node) {
+ if (name === "qt-sum")
+ edit(state, (t, s) => {
+ t[s.r][s.c].formula = W().sumFormula(t, s.r, s.c, node.dataset.dir);
+ });
+ else if (name === "qt-mark")
+ edit(state, (t, s, sheet) => {
+ const cell = t[s.r][s.c];
+ if (cell.mark) throw Error("이미 수량으로 지정된 칸입니다.");
+ const code = EstQuantity.nextItemCode(sheet);
+ cell.mark = code;
+ const name = t[s.r].find((c) => c.name)?.text ?? "";
+ sheet.items.push({
+ Code: code,
+ Name: name,
+ Size: t[s.r].find((c) => c.size)?.text ?? "",
+ GN: 0,
+ });
+ });
+ else if (name === "qt-free")
+ edit(state, (t, s, sheet) => {
+ const cell = t[s.r][s.c];
+ if (!cell.mark) throw Error("수량으로 지정된 칸이 아닙니다.");
+ const item = sheet.items.find((i) => i.Code === cell.mark);
+ if (item?.TargetCode || item?.TargetKey)
+ throw Error("내역에 연결된 수량 항목입니다. 연결 내역코드를 먼저 비우세요.");
+ sheet.items = sheet.items.filter((i) => i.Code !== cell.mark);
+ delete cell.mark;
+ });
+ else if (name === "qt-vmerge")
+ edit(state, (t, s) =>
+ W().vmerge(t, Math.min(s.r, s.r2 ?? s.r), Math.max(s.r, s.r2 ?? s.r), s.c),
+ );
+ else if (name === "qt-vsplit") edit(state, (t, s) => W().vsplit(t, s.r, s.c));
+ else if (name === "qt-create") {
+ const sheet = sheetOf(state);
+ if (!sheet) throw Error("수량산출서를 고르세요.");
+ const rows = Number(prompt("줄 수", "3")),
+ cols = Number(prompt("칸 수", "5"));
+ if (!rows || !cols) return true;
+ const at = Number(node.dataset.index);
+ EstApp.change(() => {
+ sheet.rows.splice(Number.isInteger(at) ? at + 1 : sheet.rows.length, 0, {
+ LineData: "",
+ markers: [],
+ table: W().create(rows, cols),
+ });
+ sheet.tableEdited = true;
+ }, false);
+ } else if (name === "qt-style")
+ edit(state, (t, s, sheet) => {
+ const w = Number(prompt("표 테두리 굵기(0~5)", String(sheet.rows[s.row].tableBorder ?? 1)));
+ if (!(w >= 0 && w <= 5)) throw Error("테두리 굵기는 0~5 입니다.");
+ sheet.rows[s.row].tableBorder = w;
+ });
+ else if (name.startsWith("qt-page") || name === "qt-para" || name === "qt-image")
+ lineTool(name, state, Number(node.dataset.index));
+ else if (name === "qt-outline" || name === "qt-gungu-form") {
+ const sheet = sheetOf(state);
+ if (!sheet) throw Error("수량산출서를 고르세요.");
+ const key = name === "qt-outline" ? "outline" : "gunguForm";
+ EstApp.change(() => {
+ sheet[key] = !sheet[key];
+ sheet.tableEdited = true;
+ }, false);
+ } else if (name === "qt-mul-load" || name === "qt-mul-take") mulLoad(name, state, node);
+ else if (name === "qt-print") print(state);
+ else return false;
+ return true;
+ }
+
+ // 문단(btnsetPara·mnuFormatPara)·그림 넣기(btnJPGInput)·한페이지 삽입/삭제(popPageInsert·popPageDel)
+ function lineTool(name, state, index) {
+ const sheet = sheetOf(state);
+ if (!sheet?.rows?.[index]) throw Error("산출 줄을 고르세요.");
+ const change = (fn) =>
+ EstApp.change(() => {
+ fn();
+ sheet.tableEdited = true;
+ }, false);
+ if (name === "qt-para") {
+ const align = prompt("정렬(left·center·right)", sheet.rows[index].para?.align || "left");
+ if (align == null) return;
+ if (!["left", "center", "right"].includes(align))
+ throw Error("정렬은 left·center·right 입니다.");
+ const indent = Number(prompt("들여쓰기(칸)", String(sheet.rows[index].para?.indent ?? 0)));
+ if (!(indent >= 0 && indent <= 40)) throw Error("들여쓰기는 0~40 칸입니다.");
+ // 글자모양(mnuFormatFont): 크기·굵게·색
+ const size =
+ Number(prompt("글자 크기(px, 0 = 기본)", String(sheet.rows[index].para?.size ?? 0))) || 0,
+ bold = confirm("굵게 할까요?"),
+ color =
+ prompt("글자색(#RRGGBB, 비우면 기본)", sheet.rows[index].para?.color || "")?.trim() || "";
+ if (color && !/^#[0-9a-f]{6}$/i.test(color)) throw Error("글자색은 #RRGGBB 꼴입니다.");
+ change(
+ () =>
+ (sheet.rows[index].para = {
+ align,
+ indent,
+ ...(size && { size }),
+ ...(bold && { bold }),
+ ...(color && { color }),
+ }),
+ );
+ } else if (name === "qt-image") {
+ const picker = document.createElement("input");
+ picker.type = "file";
+ picker.accept = "image/*";
+ picker.addEventListener("change", () => {
+ const file = picker.files[0];
+ if (!file) return;
+ const reader = new FileReader();
+ reader.onload = () =>
+ change(() =>
+ sheet.rows.splice(index + 1, 0, { LineData: "", markers: [], image: reader.result }),
+ );
+ reader.readAsDataURL(file);
+ });
+ picker.click();
+ } else if (name === "qt-page-insert")
+ change(() =>
+ sheet.rows.splice(
+ index + 1,
+ 0,
+ ...Array.from({ length: PAGE }, () => ({ LineData: " ", markers: [] })),
+ ),
+ );
+ else if (name === "qt-page-delete") {
+ const start = Math.floor(index / PAGE) * PAGE,
+ rows = sheet.rows.slice(start, start + PAGE);
+ if (rows.some((r) => r.markers?.length || r.table?.some((x) => x.some((c) => c.mark))))
+ throw Error("수량으로 지정된 줄이 있는 쪽은 지울 수 없습니다. 먼저 지정 해제하세요.");
+ if (rows.some((r) => r.image) && !confirm("그림이 있는 쪽입니다. 지우시겠습니까?")) return;
+ if (!confirm(`${start + 1}~${start + rows.length}줄(한 쪽)을 지우시겠습니까?`)) return;
+ change(() => sheet.rows.splice(start, PAGE));
+ }
+ }
+
+ // 수량 불러오기(btnMulLoad): Main Data(원본 main.estx) 수량산출서 목록에서 골라 새 산출서로(새 코드, 본문 그대로)
+ async function mulLoad(name, state, node) {
+ const { esc } = EstApp;
+ if (name === "qt-mul-load") {
+ state.mulLoad ||= (await EstApp.api("/api/main-data?part=quantity")).quantitySheets;
+ EstApp.showDialog(
+ "수량 불러오기",
+ `
${state.mulLoad.map((s, i) => `
${esc(s.Code)} ${esc(s.Name)}${s.excel ? " (엑셀형)" : ""}
`).join("")}
`,
+ );
+ return;
+ }
+ const src = state.mulLoad[Number(node.dataset.i)];
+ EstApp.change((project) => {
+ const copy = structuredClone(src);
+ copy.Code = `Q${EstApp.uid()}`;
+ for (const item of copy.items || []) delete item.TargetCode;
+ (project.quantitySheets ||= []).push(copy);
+ state.quantitySheet = copy.Code;
+ }, false);
+ EstApp.byId("dialog").close();
+ state.notice = `「${src.Name}」 산출서를 불러왔습니다.`;
+ }
+
+ // 수량산출서 인쇄(btnPrint → TfrmMulPrintRange): 현재 산출서 또는 모두(산출서마다 간지 고르기), 산출 줄·표 그대로
+ function print(state) {
+ const sheets = state.project.quantitySheets || [];
+ if (!sheets.length) throw Error("수량산출서가 없습니다.");
+ const all = confirm("모든 수량산출서를 인쇄할까요? (취소 = 현재 산출서만)"),
+ gangi = all && confirm("산출서마다 간지를 넣을까요?"),
+ list = all ? sheets : [sheetOf(state)].filter(Boolean),
+ { esc } = EstApp;
+ const body = list
+ .map((sheet) => {
+ const rows = sheet.rows
+ .map((r) =>
+ r.image
+ ? `
})
`
+ : r.table
+ ? `
${r.table.map((tr) => `${tr.map((c) => (c.vmerge ? "" : `| ${esc(c.text)} | `)).join("")}
`).join("")}
`
+ : `
${esc(EstText.clean(r.LineData || ""))}
`,
+ )
+ .join("");
+ return `${gangi ? `
` : ""}
${esc(sheet.Name)} ${esc(sheet.Size || "")}
${rows}`;
+ })
+ .join("");
+ const win = open("", "_blank");
+ if (!win) throw Error("새 창을 열 수 없습니다. 팝업 차단을 확인하세요.");
+ win.document.write(
+ `
수량산출서${body}