Merge branch 'sub_laptop_2' of C:/Aislo_wt/sub_laptop_2 into main_laptop_1
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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}
|
||||
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""EST 테스트 앱 워드형 수량산출서 표(원본 WPT <table> 보존 · 방향 셀 합 · 수량 표지 · 세로 병합)."""
|
||||
|
||||
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 = '<begin Tag=1 x=[[Name:"N000001"]]/><begin Tag=2 x=[[Name:"G00000203"]]/>' +
|
||||
'<div>단가 <begin Tag=2/>9<end Tag=2/></div>' +
|
||||
'<table><tr><td>공종</td><td>수량</td></tr>' +
|
||||
'<tr><td><begin Tag=1/>절단<end Tag=1/></td><td><begin Tag=2/>1,000.5<end Tag=2/></td></tr>' +
|
||||
'<tr><td>깨기</td><td>2</td></tr><tr><td>계</td><td></td></tr></table>';
|
||||
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],
|
||||
]
|
||||
@@ -10,6 +10,7 @@ FILES = [
|
||||
"native-formula.js",
|
||||
"native-text.js",
|
||||
"native-quantity-total.js",
|
||||
"native-quantity-wpt.cjs",
|
||||
"native-quantity.cjs",
|
||||
]
|
||||
SCRIPT = r"""(() => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -489,6 +489,9 @@ 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] 워드형 수량산출서 표와 편집 도구(원본 TFRMMULWORD·TFRMMULLANG — spec_word · spec_pa) — 가져오기가 WPT <table> 을 통째로 버리던 결함 고침: 표를 한 줄 { 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 값으로 덮음).
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,7 +13,11 @@
|
||||
<script defer src="native-excel-sheet.cjs"></script>
|
||||
<script defer src="native-excel-edit.cjs"></script>
|
||||
<script defer src="native-quantity-total.js"></script>
|
||||
<script defer src="native-quantity-wpt.cjs"></script>
|
||||
<script defer src="native-quantity.cjs"></script>
|
||||
<script defer src="native-quantity-table.js"></script>
|
||||
<script defer src="native-quantity-list.js"></script>
|
||||
<script defer src="native-actions-more.js"></script>
|
||||
<script defer src="native-wonga.js"></script>
|
||||
<script defer src="native-jungsang.js"></script>
|
||||
<script defer src="native-sources.js"></script>
|
||||
@@ -304,6 +308,7 @@
|
||||
><button id="uses-search-btn" data-action="uses-data-search">사용위치</button
|
||||
><button data-action="book">품셈</button
|
||||
><button data-action="book-paste">품셈자료 붙여넣기</button
|
||||
><button data-action="mul-paste">수량붙여넣기(연결)</button
|
||||
><button data-action="add-row">행 추가</button><button data-action="copy-row">복사</button
|
||||
><button data-action="paste-row">붙여넣기</button
|
||||
><button data-action="delete-row">삭제</button
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// 동작 사슬 이어 붙이기(native-business-actions.js 가 700줄 한계라 새 모듈 처리기는 여기서 차례로 부른다).
|
||||
const EstActionsMore = (() => {
|
||||
const modules = () => [typeof EstQuantityList !== "undefined" && EstQuantityList].filter(Boolean);
|
||||
function handle(name, state, node) {
|
||||
for (const module of modules()) if (module.handle(name, state, node)) return true;
|
||||
return false;
|
||||
}
|
||||
return { handle };
|
||||
})();
|
||||
@@ -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") {
|
||||
|
||||
@@ -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
|
||||
? `<table class="sheet">${r.table.map((tr) => `<tr>${tr.map((c) => `<td>${esc(c.text)}</td>`).join("")}</tr>`).join("")}</table>`
|
||||
: `<div style="white-space:pre-wrap">${esc(EstText.clean(r.LineData || ""))}</div>`,
|
||||
)
|
||||
.join("")
|
||||
: "";
|
||||
EstApp.showDialog(
|
||||
"수량 목록",
|
||||
`<div class="business-tools"><input data-ql-word value="${esc(l.word)}" placeholder="검색어"><button data-action="ql-search">검 색</button><button data-action="ql-all">전체보기</button><button data-action="ql-copy">선택수량복사</button><label><input type="checkbox" data-ql-namesize ${l.nameSize ? "checked" : ""}> 품명,규격입력</label></div><div style="display:flex;gap:8px;height:56vh"><div style="flex:0 0 26%;overflow:auto;border:1px solid #ccc">${sheets.map((s) => `<div><a href="#" data-ql-sheet="${esc(s.Code)}"${s.Code === l.code ? ' style="font-weight:bold"' : ""}>${esc(s.Name)} ${esc(s.Size || "")}</a></div>`).join("")}</div><div style="flex:0 0 34%;overflow:auto;border:1px solid #ccc"><table class="sheet"><thead><tr><th></th><th>명칭</th><th>규격</th><th>단위</th><th>수량</th></tr></thead><tbody>${items.map((i, k) => `<tr><td><input type="checkbox" data-ql-item="${k}" ${l.picked.has(k) ? "checked" : ""}></td><td>${esc(i.Name)}</td><td>${esc(i.Size)}</td><td>${esc(i.Unite)}</td><td>${number(i.GN)}</td></tr>`).join("")}</tbody></table></div><div style="flex:1;overflow:auto;border:1px solid #ccc;padding:4px;font-size:12px">${preview}</div></div>`,
|
||||
);
|
||||
}
|
||||
|
||||
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 };
|
||||
})();
|
||||
@@ -129,7 +129,7 @@ const EstQuantitySheets = (() => {
|
||||
`<option value="${esc(sheet.Code)}" ${state.quantitySheet === sheet.Code ? "selected" : ""}>${esc(sheet.Name || sheet.Code)}</option>`,
|
||||
)
|
||||
.join("");
|
||||
return `<div class="business-tools"><label>수량산출서 <select id="quantity-sheet-choice"><option value="manual">수식표 직접 작성</option>${options}</select></label><input data-quantity-query value="${esc(state.quantityQuery || "")}" placeholder="명칭·규격" aria-label="산출서 검색"><button data-action="quantity-sheet-search">검색</button><button data-action="quantity-sheet-all">전체보기</button><button data-quantity-new>새 산출서</button><button data-action="quantity-sheet-insert">삽입</button><button data-action="quantity-sheet-multi-delete">선택공종 삭제</button><button data-quantity-excel>Excel 셀 수량 연결</button><button data-quantity-excel-links>Excel 연결 관리</button></div>`;
|
||||
return `<div class="business-tools"><label>수량산출서 <select id="quantity-sheet-choice"><option value="manual">수식표 직접 작성</option>${options}</select></label><input data-quantity-query value="${esc(state.quantityQuery || "")}" placeholder="명칭·규격" aria-label="산출서 검색"><button data-action="quantity-sheet-search">검색</button><button data-action="quantity-sheet-all">전체보기</button><button data-quantity-new>새 산출서</button><button data-action="quantity-sheet-insert">삽입</button><button data-action="quantity-sheet-multi-delete">선택공종 삭제</button><button data-action="qt-mul-load">불러오기</button><button data-action="quantity-list">수량목록보기</button><button data-action="quantity-title">공종명칭 입력</button><button data-action="qt-outline">테두리선</button><button data-action="qt-gungu-form">산출근거 양식</button><button data-quantity-excel>Excel 셀 수량 연결</button><button data-quantity-excel-links>Excel 연결 관리</button></div>`;
|
||||
}
|
||||
|
||||
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 `<p><button data-action="excel-mode">${on ? "일반형식으로" : "엑셀형식으로"}</button></p>${on ? EstExcelView.render(sheet, EstApp.state) : ""}`;
|
||||
return `${typeof EstQuantityTable !== "undefined" ? EstQuantityTable.banner(sheet) : ""}<p><button data-action="excel-mode">${on ? "일반형식으로" : "엑셀형식으로"}</button></p>${on ? EstExcelView.render(sheet, EstApp.state) : ""}`;
|
||||
}
|
||||
function excelMode(state) {
|
||||
const sheet = state.project.quantitySheets?.find((s) => s.Code === state.quantitySheet);
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
// 워드형 수량산출서 표 화면·편집 — 칸 고르기(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",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(";");
|
||||
}
|
||||
// 테두리선(popFormatOutLine)·산출근거 양식(popFormatGungu): 화면에만 그림(원본도 글로 넣지 않음)
|
||||
function banner(sheet) {
|
||||
return sheet.gunguForm
|
||||
? `<table class="sheet" style="${sheet.outline ? "border:2px solid #333;" : ""}margin:4px 0"><tr><th>공 종</th><th>산 출 내 역</th><th>수 량</th></tr></table>`
|
||||
: sheet.outline
|
||||
? '<p class="business-note">테두리선 켬(인쇄 쪽 둘레)</p>'
|
||||
: "";
|
||||
}
|
||||
function html(sheet, index, state) {
|
||||
const { esc } = EstApp;
|
||||
if (sheet.rows[index].image)
|
||||
return `<img src="${esc(sheet.rows[index].image)}" alt="산출서 그림" style="max-width:600px;max-height:400px">`;
|
||||
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
|
||||
? `<div class="business-tools">${W().letters(sel.c)}${sel.r + 1} <input data-qt-bar value="${esc(cur?.formula ? `=${cur.formula}` : (cur?.text ?? ""))}" aria-label="칸 글·수식" style="width:30%">${[
|
||||
["right", "우측 합"],
|
||||
["left", "좌측 합"],
|
||||
["up", "위 합"],
|
||||
["down", "아래 합"],
|
||||
]
|
||||
.map(([d, l]) => `<button data-action="qt-sum" data-dir="${d}">${l}</button>`)
|
||||
.join(
|
||||
"",
|
||||
)}<button data-action="qt-mark">수량으로 지정</button><button data-action="qt-free">지정 해제</button><button data-action="qt-vmerge">셀수직병합</button><button data-action="qt-vsplit">셀분리</button><button data-action="qt-style">표 모양</button></div>`
|
||||
: "";
|
||||
const body = table
|
||||
.map(
|
||||
(row, r) =>
|
||||
`<tr>${row
|
||||
.map((cell, c) =>
|
||||
cell.vmerge
|
||||
? ""
|
||||
: `<td data-qt="${index},${r},${c}"${cell.vtop ? ` rowspan="${cell.vtop}"` : ""} style="${[
|
||||
cell.mark ? "background:#e6f4ea" : "",
|
||||
inBox(r, c) ? "outline:2px solid #316ea5" : "",
|
||||
cell.formula ? "font-weight:bold" : "",
|
||||
cell.name ? "color:#1d4ed8" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(
|
||||
";",
|
||||
)}"${cell.mark || cell.formula ? ` title="${esc([cell.mark && `수량: ${cell.mark}`, cell.formula && `=${cell.formula}`].filter(Boolean).join(" · "))}"` : ""}>${esc(cell.text) || " "}</td>`,
|
||||
)
|
||||
.join("")}</tr>`,
|
||||
)
|
||||
.join("");
|
||||
const border = sheet.rows[index].tableBorder ?? 1;
|
||||
return `${tools}<table class="sheet" style="font-size:12px;${border === 1 ? "" : `border:${border}px solid #000`}">${body}</table>`;
|
||||
}
|
||||
|
||||
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 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 칸입니다.");
|
||||
change(() => (sheet.rows[index].para = { align, indent }));
|
||||
} 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(
|
||||
"수량 불러오기",
|
||||
`<div style="max-height:420px;overflow:auto">${state.mulLoad.map((s, i) => `<div><button data-action="qt-mul-take" data-i="${i}">가져오기</button> ${esc(s.Code)} ${esc(s.Name)}${s.excel ? " (엑셀형)" : ""}</div>`).join("")}</div>`,
|
||||
);
|
||||
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}」 산출서를 불러왔습니다.`;
|
||||
}
|
||||
|
||||
function install() {
|
||||
document.addEventListener("click", (event) => {
|
||||
const td = event.target.closest("[data-qt]");
|
||||
if (!td) return;
|
||||
const [row, r, c] = td.dataset.qt.split(",").map(Number),
|
||||
state = EstApp.state,
|
||||
old = state.qtCell;
|
||||
state.qtCell =
|
||||
event.shiftKey && old && old.row === row && old.sheet === state.quantitySheet
|
||||
? { ...old, r2: r, c2: c }
|
||||
: { sheet: state.quantitySheet, row, r, c };
|
||||
EstApp.render();
|
||||
});
|
||||
document.addEventListener("change", (event) => {
|
||||
if (event.target.dataset.qtBar === undefined) return;
|
||||
const v = event.target.value,
|
||||
state = EstApp.state;
|
||||
try {
|
||||
edit(state, (t, s) => {
|
||||
const cell = t[s.r][s.c];
|
||||
if (v.trim().startsWith("=")) cell.formula = v.trim().slice(1);
|
||||
else {
|
||||
delete cell.formula;
|
||||
cell.text = v;
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
state.notice = error.message;
|
||||
EstApp.render();
|
||||
}
|
||||
});
|
||||
}
|
||||
if (typeof document !== "undefined" && document.addEventListener) install();
|
||||
return { html, banner, paraStyle, handle };
|
||||
})();
|
||||
@@ -0,0 +1,190 @@
|
||||
// 워드형 수량산출서(TFRMMULWORD, WPTools 문서) 읽기와 표 — spec_word.
|
||||
// 문단(<div>) 은 산출 줄, 표(<table>) 는 한 줄 { table: [[칸]] } 로 둔다. 칸 = { text, mark(수량 표지 이름 r·L·G), name·size(품명·규격 표지),
|
||||
// formula(=SUM(B3:B5) — 원본 PAR_COMMAND), vmerge(세로 병합으로 가려짐) }.
|
||||
// 방향 셀 합(ActionAddRight·Left·Up·Down 0xd3c5f0): 옆 칸부터 밖으로 — 빈칸·숫자 칸은 범위를 늘리고 글자 칸은 건너뛰며,
|
||||
// 품명·규격 표지 칸이나 표 끝에서 멈춤. 칸에 SUM 수식을 두고 바로 계산(값 글자 = 1,234.5 꼴, 소수 9자리까지, 끝수 처리 없음).
|
||||
// 계산 때마다 수식 칸을 왼쪽 위부터 한 번씩 다시 셈. 수량 표지 칸의 수는 그 항목 수량.
|
||||
const EstQuantityWpt = (() => {
|
||||
function entities(text) {
|
||||
return String(text)
|
||||
.replace(/&#(x[0-9a-f]+|\d+);/gi, (_, n) => {
|
||||
const code = n[0].toLowerCase() === "x" ? parseInt(n.slice(1), 16) : Number(n);
|
||||
return code <= 0x10ffff ? String.fromCodePoint(code) : "";
|
||||
})
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'|'/g, "'")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/ /g, " ")
|
||||
.replace(/&/g, "&");
|
||||
}
|
||||
const tag = (name) =>
|
||||
/^[rLG]\d+$/.test(name)
|
||||
? "\x12"
|
||||
: /^[Fa]\d+$/.test(name)
|
||||
? "\x16"
|
||||
: /^N\d+$/.test(name)
|
||||
? "\x0e"
|
||||
: /^S\d+$/.test(name)
|
||||
? "\x13"
|
||||
: "";
|
||||
|
||||
function fromWpt(code, name, text, items, options) {
|
||||
const tags = new Map(),
|
||||
rows = [];
|
||||
const superscripts = new Set(
|
||||
[...text.matchAll(/<cs\b[^>]*\bnr=(\d+)[^>]*>/gi)]
|
||||
.filter((m) => (Number(m[0].match(/CharStyleON:(\d+)/)?.[1] || 0) & 16) !== 0)
|
||||
.map((m) => m[1]),
|
||||
);
|
||||
for (const match of text.matchAll(/<begin\b[^>]*\bTag=(\d+)[^>]*>/gi)) {
|
||||
const name = entities(match[0]).match(/Name:["']([^"']+)["']/)?.[1];
|
||||
if (name) tags.set(match[1], name);
|
||||
}
|
||||
// 문단·칸 글 → 표지 글자(\x12 결과·\x16 누계·\x0e 품명·\x13 규격)
|
||||
function convert(html, cell = false) {
|
||||
// 문단의 G 표지는 원본처럼 표지로 보지 않음(표 칸에서만 수량)
|
||||
const tagOf = (n) => (!cell && /^G/.test(n) ? "" : tag(n));
|
||||
const markers = [],
|
||||
totals = [],
|
||||
names = [];
|
||||
let raised = false;
|
||||
let body = html.replace(/<c\s+nr=(\d+)\s*\/>/gi, (_, id) => {
|
||||
const next = superscripts.has(id),
|
||||
value = next && !raised ? "^(" : !next && raised ? ")" : "";
|
||||
raised = next;
|
||||
return value;
|
||||
});
|
||||
if (raised) body += ")";
|
||||
body = body
|
||||
.replace(/<begin\b[^>]*\bTag=(\d+)[^>]*>/gi, (_, id) => {
|
||||
const name = tags.get(id) || "";
|
||||
names.push(name);
|
||||
if (tagOf(name) === "\x12" && !/^G/.test(name)) markers.push(name);
|
||||
if (tagOf(name) === "\x16") totals.push(name);
|
||||
return tagOf(name);
|
||||
})
|
||||
.replace(/<end\b[^>]*\bTag=(\d+)[^>]*>/gi, (_, id) => tagOf(tags.get(id) || ""));
|
||||
return {
|
||||
body: entities(body.replace(/<[^>]*>/g, "").replace(/\r?\n/g, "")),
|
||||
markers,
|
||||
totals,
|
||||
names,
|
||||
};
|
||||
}
|
||||
for (const match of text.matchAll(
|
||||
/<table\b[\s\S]*?<\/table>|<div\b[^>]*>([\s\S]*?)<\/div>/gi,
|
||||
)) {
|
||||
if (/^<table/i.test(match[0])) {
|
||||
const table = [...match[0].matchAll(/<tr\b[^>]*>([\s\S]*?)<\/tr>/gi)].map((tr) =>
|
||||
[...tr[1].matchAll(/<td\b[^>]*>([\s\S]*?)<\/td>/gi)].map((td) => {
|
||||
const c = convert(td[1], true),
|
||||
mark = c.names.find((n) => tag(n) === "\x12");
|
||||
return {
|
||||
text: c.body.replace(/[\x0e\x12\x13\x16]/g, "").trim(),
|
||||
...(mark && { mark }),
|
||||
...(c.names.some((n) => /^N\d+$/.test(n)) && { name: true }),
|
||||
...(c.names.some((n) => /^S\d+$/.test(n)) && { size: true }),
|
||||
};
|
||||
}),
|
||||
);
|
||||
if (table.length) rows.push({ LineData: "", markers: [], table });
|
||||
continue;
|
||||
}
|
||||
const c = convert(match[1]);
|
||||
if (c.body.trim())
|
||||
rows.push({
|
||||
LineData: c.body,
|
||||
markers: c.markers,
|
||||
...(c.totals.length && { totals: c.totals }),
|
||||
});
|
||||
}
|
||||
return { Code: code, Name: name, rows, items, options };
|
||||
}
|
||||
|
||||
// ── 표 계산 ──
|
||||
const L = (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 numeric = (t) => /^[+-]?[\d,]*\.?\d+$/.test(String(t).trim());
|
||||
const value = (t) => (numeric(t) ? Number(String(t).replaceAll(",", "")) : 0);
|
||||
const show = (v) => v.toLocaleString("en-US", { maximumFractionDigits: 9 });
|
||||
function recalc(table) {
|
||||
const errors = [];
|
||||
table.forEach((row) =>
|
||||
row.forEach((cell) => {
|
||||
if (!cell.formula) return;
|
||||
try {
|
||||
const X =
|
||||
typeof EstExcelSheet !== "undefined"
|
||||
? EstExcelSheet
|
||||
: require("./native-excel-sheet.cjs");
|
||||
const v = X.evaluate(cell.formula, (r, c) => value(table[r]?.[c]?.text ?? ""));
|
||||
cell.text = show(typeof v === "number" ? v : Number(v) || 0);
|
||||
} catch (error) {
|
||||
errors.push(error.message);
|
||||
}
|
||||
}),
|
||||
);
|
||||
return errors;
|
||||
}
|
||||
// 항목 수량: 수량 표지 칸의 수
|
||||
function values(sheet) {
|
||||
const out = new Map();
|
||||
for (const row of sheet.rows || []) {
|
||||
if (!row.table) continue;
|
||||
recalc(row.table);
|
||||
for (const r of row.table)
|
||||
for (const c of r) if (c.mark && !c.vmerge) out.set(c.mark, value(c.text));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function sumFormula(table, r, c, dir) {
|
||||
const [dr, dc] = { right: [0, -1], left: [0, 1], up: [1, 0], down: [-1, 0] }[dir] || [];
|
||||
if (dr == null) throw Error("방향을 고르세요.");
|
||||
// 방향 = 결과 칸이 있는 쪽(원본 Ctrl+← 는 오른쪽 칸들을 더함)
|
||||
let rr = r + dr,
|
||||
cc = c + dc,
|
||||
last = null;
|
||||
const first = [rr, cc];
|
||||
while (table[rr]?.[cc]) {
|
||||
const cell = table[rr][cc];
|
||||
if (cell.name || cell.size) break;
|
||||
if (!cell.text.trim() || numeric(cell.text)) last = [rr, cc];
|
||||
rr += dr;
|
||||
cc += dc;
|
||||
}
|
||||
if (!table[first[0]]?.[first[1]]) throw Error("더할 셀이 없습니다.");
|
||||
if (!last) last = dir === "right" ? [r, 0] : first;
|
||||
const a = [Math.min(first[0], last[0]), Math.min(first[1], last[1])],
|
||||
b = [Math.max(first[0], last[0]), Math.max(first[1], last[1])];
|
||||
return `SUM(${L(a[1])}${a[0] + 1}:${L(b[1])}${b[0] + 1})`;
|
||||
}
|
||||
function vmerge(table, r1, r2, c) {
|
||||
if (r2 <= r1) throw Error("세로로 여러 칸을 고르세요.");
|
||||
const top = table[r1][c];
|
||||
for (let r = r1 + 1; r <= r2; r++) {
|
||||
const cell = table[r][c];
|
||||
if (cell.text.trim()) top.text = `${top.text} ${cell.text}`.trim();
|
||||
cell.text = "";
|
||||
cell.vmerge = true;
|
||||
}
|
||||
top.vtop = r2 - r1 + 1;
|
||||
}
|
||||
function vsplit(table, r, c) {
|
||||
const top = table[r][c];
|
||||
for (let i = r + 1; i < r + (top.vtop || 1); i++) if (table[i]?.[c]) delete table[i][c].vmerge;
|
||||
delete top.vtop;
|
||||
}
|
||||
function create(rows, cols) {
|
||||
if (!(rows >= 1 && rows <= 100 && cols >= 1 && cols <= 30))
|
||||
throw Error("줄 1~100, 칸 1~30 입니다.");
|
||||
return Array.from({ length: rows }, () => Array.from({ length: cols }, () => ({ text: "" })));
|
||||
}
|
||||
|
||||
return { entities, fromWpt, recalc, values, sumFormula, vmerge, vsplit, create, letters: L };
|
||||
})();
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = EstQuantityWpt;
|
||||
@@ -13,52 +13,10 @@ const EstQuantity = (() => {
|
||||
.replace(/ /g, " ")
|
||||
.replace(/&/g, "&");
|
||||
}
|
||||
function fromWpt(code, name, text, items, options) {
|
||||
const tags = new Map(),
|
||||
rows = [];
|
||||
const superscripts = new Set(
|
||||
[...text.matchAll(/<cs\b[^>]*\bnr=(\d+)[^>]*>/gi)]
|
||||
.filter((m) => (Number(m[0].match(/CharStyleON:(\d+)/)?.[1] || 0) & 16) !== 0)
|
||||
.map((m) => m[1]),
|
||||
);
|
||||
for (const match of text.matchAll(/<begin\b[^>]*\bTag=(\d+)[^>]*>/gi)) {
|
||||
const name = entities(match[0]).match(/Name:["']([^"']+)["']/)?.[1];
|
||||
if (name) tags.set(match[1], name);
|
||||
}
|
||||
for (const match of text.matchAll(/<div\b[^>]*>([\s\S]*?)<\/div>/gi)) {
|
||||
const markers = [],
|
||||
totals = [];
|
||||
let raised = false;
|
||||
const tag = (name) =>
|
||||
/^r\d+$/.test(name) || /^L\d+$/.test(name)
|
||||
? "\x12"
|
||||
: /^[Fa]\d+$/.test(name)
|
||||
? "\x16"
|
||||
: /^N\d+$/.test(name)
|
||||
? "\x0e"
|
||||
: /^S\d+$/.test(name)
|
||||
? "\x13"
|
||||
: "";
|
||||
let body = match[1].replace(/<c\s+nr=(\d+)\s*\/>/gi, (_, id) => {
|
||||
const next = superscripts.has(id),
|
||||
value = next && !raised ? "^(" : !next && raised ? ")" : "";
|
||||
raised = next;
|
||||
return value;
|
||||
});
|
||||
if (raised) body += ")";
|
||||
body = body
|
||||
.replace(/<begin\b[^>]*\bTag=(\d+)[^>]*>/gi, (_, id) => {
|
||||
const name = tags.get(id) || "";
|
||||
if (tag(name) === "\x12") markers.push(name);
|
||||
if (tag(name) === "\x16") totals.push(name);
|
||||
return tag(name);
|
||||
})
|
||||
.replace(/<end\b[^>]*\bTag=(\d+)[^>]*>/gi, (_, id) => tag(tags.get(id) || ""));
|
||||
body = entities(body.replace(/<[^>]*>/g, "").replace(/\r?\n/g, ""));
|
||||
if (body.trim()) rows.push({ LineData: body, markers, ...(totals.length && { totals }) });
|
||||
}
|
||||
return { Code: code, Name: name, rows, items, options };
|
||||
}
|
||||
// 워드형 문서 읽기·표는 native-quantity-wpt.cjs
|
||||
const wpt = () =>
|
||||
typeof EstQuantityWpt !== "undefined" ? EstQuantityWpt : require("./native-quantity-wpt.cjs");
|
||||
const fromWpt = (...args) => wpt().fromWpt(...args);
|
||||
function calculate(sheet) {
|
||||
const markerErrors = sheet.rows.flatMap((row, index) => {
|
||||
const count = [...String(row.LineData || "").matchAll(/\x12([-\d.,]+)\x12/g)].length;
|
||||
@@ -82,7 +40,10 @@ const EstQuantity = (() => {
|
||||
};
|
||||
return {
|
||||
...row,
|
||||
LineData: row.LineData.replace(/\*개소당|\bPIPE\b|소\s*계|∴|\x16[^\x16]*\x16/g, ""),
|
||||
LineData:
|
||||
row.table || row.image
|
||||
? ""
|
||||
: row.LineData.replace(/\*개소당|\bPIPE\b|소\s*계|∴|\x16[^\x16]*\x16/g, ""),
|
||||
Index: 0,
|
||||
SCheck: 0,
|
||||
LinkIndex: 0,
|
||||
@@ -118,13 +79,18 @@ const EstQuantity = (() => {
|
||||
});
|
||||
if (typeof EstQuantityTotal !== "undefined")
|
||||
EstQuantityTotal.apply(sheet, result.rows.slice(1));
|
||||
const tableValues = sheet.rows.some((row) => row.table) ? wpt().values(sheet) : null;
|
||||
const cellValues =
|
||||
sheet.excel && typeof EstExcelSheet !== "undefined" ? EstExcelSheet.itemValues(sheet) : null;
|
||||
return {
|
||||
rows: result.rows.slice(1),
|
||||
items: sheet.items.map((item) => ({
|
||||
...item,
|
||||
GN: values.get(item.Code) ?? cellValues?.get(item.Code) ?? item.GN,
|
||||
GN:
|
||||
values.get(item.Code) ??
|
||||
tableValues?.get(item.Code) ??
|
||||
cellValues?.get(item.Code) ??
|
||||
item.GN,
|
||||
})),
|
||||
checks,
|
||||
errors: [...result.errors, ...markerErrors],
|
||||
@@ -325,7 +291,7 @@ const EstQuantity = (() => {
|
||||
{ esc, number, byId } = EstApp;
|
||||
byId("work-content").innerHTML =
|
||||
selector(state) +
|
||||
`<div class="business-tools"><label>산출서명 <input data-quantity-name value="${esc(sheet.Name)}"></label><button data-quantity-add-item>수량 항목 추가</button><button data-quantity-add>산출 행 추가</button><button data-quantity-apply>내역 수량 반영</button><button data-action="quantity-sheet-copy">산출서 복사</button><button data-action="quantity-sheet-up">산출서 위로</button><button data-action="quantity-sheet-down">산출서 아래로</button><button data-quantity-delete-sheet>산출서 삭제</button></div><table class="sheet"><thead><tr><th>산출코드</th><th>품명</th><th>규격</th><th>단위</th><th>저장 수량</th><th>재계산 수량</th><th>연결 내역코드</th><th>끝수 처리</th><th>작업</th></tr></thead><tbody>${output.items.map((item, index) => `<tr><td>${esc(item.Code)}</td><td><input data-quantity-item="Name" data-index="${index}" value="${esc(item.Name || "")}" aria-label="품명"></td><td><input data-quantity-item="Size" data-index="${index}" value="${esc(item.Size || "")}" aria-label="규격"></td><td><input data-quantity-item="Unite" data-index="${index}" value="${esc(item.Unite || "")}" aria-label="단위"></td><td>${number(sheet.items[index]?.GN ?? 0)}</td><td>${number(item.GN)}</td><td style="white-space:normal"><input data-quantity-target="${index}" value="${esc(item.TargetCode ?? item.LinkCode ?? "")}" aria-label="연결 내역코드">${targetSelect(state.project, item, "sheet", index)}</td><td>${formatSelect(sheet.items[index], index)}</td><td><button data-quantity-delete-item="${index}">삭제</button></td></tr>`).join("")}</tbody></table>${typeof EstQuantitySheets !== "undefined" ? EstQuantitySheets.excelView(sheet) : ""}<p class="business-note">${esc(output.errors.join(" / "))} 산출 행의 결과 표지로 수량 항목을 연결합니다. 신규 산출서와 지원되지 않는 원본 구조 변경은 JSON으로 저장합니다.</p><table class="sheet"><thead><tr><th>줄</th><th>산출근거 편집</th><th>작업</th></tr></thead><tbody>${sheet.rows.map((row, index) => `<tr><td>${index + 1}</td><td><textarea data-quantity-line="${index}" rows="1" style="min-width:750px" aria-label="산출근거 ${index + 1}">${esc(EstText.display(output.rows[index].LineData))}</textarea></td><td><button data-quantity-add data-index="${index}">아래 추가</button><button data-quantity-move="-1" data-index="${index}" ${index === 0 ? "disabled" : ""}>↑</button><button data-quantity-move="1" data-index="${index}" ${index === sheet.rows.length - 1 ? "disabled" : ""}>↓</button><button data-action="quantity-mark" data-index="${index}">수량 지정</button><button data-action="quantity-free" data-index="${index}">지정 해제</button><button data-action="quantity-total" data-index="${index}">누계</button><button data-action="quantity-row-copy" data-index="${index}">복사</button><button data-action="quantity-row-cut" data-index="${index}">잘라내기</button><button data-action="quantity-row-paste" data-index="${index}">아래 붙여넣기</button><button data-quantity-delete-row="${index}">삭제</button></td></tr>`).join("")}</tbody></table>`;
|
||||
`<div class="business-tools"><label>산출서명 <input data-quantity-name value="${esc(sheet.Name)}"></label><button data-quantity-add-item>수량 항목 추가</button><button data-quantity-add>산출 행 추가</button><button data-quantity-apply>내역 수량 반영</button><button data-action="quantity-sheet-copy">산출서 복사</button><button data-action="quantity-sheet-up">산출서 위로</button><button data-action="quantity-sheet-down">산출서 아래로</button><button data-quantity-delete-sheet>산출서 삭제</button></div><table class="sheet"><thead><tr><th>산출코드</th><th>품명</th><th>규격</th><th>단위</th><th>저장 수량</th><th>재계산 수량</th><th>연결 내역코드</th><th>끝수 처리</th><th>작업</th></tr></thead><tbody>${output.items.map((item, index) => `<tr><td>${esc(item.Code)}</td><td><input data-quantity-item="Name" data-index="${index}" value="${esc(item.Name || "")}" aria-label="품명"></td><td><input data-quantity-item="Size" data-index="${index}" value="${esc(item.Size || "")}" aria-label="규격"></td><td><input data-quantity-item="Unite" data-index="${index}" value="${esc(item.Unite || "")}" aria-label="단위"></td><td>${number(sheet.items[index]?.GN ?? 0)}</td><td>${number(item.GN)}</td><td style="white-space:normal"><input data-quantity-target="${index}" value="${esc(item.TargetCode ?? item.LinkCode ?? "")}" aria-label="연결 내역코드">${targetSelect(state.project, item, "sheet", index)}</td><td>${formatSelect(sheet.items[index], index)}</td><td><button data-quantity-delete-item="${index}">삭제</button></td></tr>`).join("")}</tbody></table>${typeof EstQuantitySheets !== "undefined" ? EstQuantitySheets.excelView(sheet) : ""}<p class="business-note">${esc(output.errors.join(" / "))} 산출 행의 결과 표지로 수량 항목을 연결합니다. 신규 산출서와 지원되지 않는 원본 구조 변경은 JSON으로 저장합니다.</p><table class="sheet"><thead><tr><th>줄</th><th>산출근거 편집</th><th>작업</th></tr></thead><tbody>${sheet.rows.map((row, index) => `<tr><td>${index + 1}</td><td>${(sheet.rows[index].table || sheet.rows[index].image) && typeof EstQuantityTable !== "undefined" ? EstQuantityTable.html(sheet, index, state) : `<textarea data-quantity-line="${index}" rows="1" style="min-width:750px;${typeof EstQuantityTable !== "undefined" ? EstQuantityTable.paraStyle(sheet.rows[index]) : ""}" aria-label="산출근거 ${index + 1}">${esc(EstText.display(output.rows[index].LineData))}</textarea>`}</td><td><button data-quantity-add data-index="${index}">아래 추가</button><button data-quantity-move="-1" data-index="${index}" ${index === 0 ? "disabled" : ""}>↑</button><button data-quantity-move="1" data-index="${index}" ${index === sheet.rows.length - 1 ? "disabled" : ""}>↓</button><button data-action="quantity-mark" data-index="${index}">수량 지정</button><button data-action="quantity-free" data-index="${index}">지정 해제</button><button data-action="quantity-total" data-index="${index}">누계</button><button data-action="quantity-row-copy" data-index="${index}">복사</button><button data-action="quantity-row-cut" data-index="${index}">잘라내기</button><button data-action="quantity-row-paste" data-index="${index}">아래 붙여넣기</button><button data-action="qt-create" data-index="${index}">아래 표 만들기</button><button data-action="qt-para" data-index="${index}">문단</button><button data-action="quantity-name" data-index="${index}">품명지정</button><button data-action="quantity-size" data-index="${index}">규격지정</button><button data-action="qt-image" data-index="${index}">그림</button><button data-action="qt-page-insert" data-index="${index}">한페이지 삽입</button><button data-action="qt-page-delete" data-index="${index}">한페이지 삭제</button><button data-quantity-delete-row="${index}">삭제</button></td></tr>`).join("")}</tbody></table>`;
|
||||
}
|
||||
// 원본 수량목록 자리수 메뉴(mnuAuto·mnuTrunc·mnuUp·mnuRound): 결과 코드 뒤 방식(1 반올림·2 절사·3 올림)+자리수.
|
||||
function resultFormat(code) {
|
||||
|
||||
@@ -28,6 +28,7 @@ const scripts = [
|
||||
"native-text.js",
|
||||
"native-excel-sheet.cjs",
|
||||
"native-quantity-total.js",
|
||||
"native-quantity-wpt.cjs",
|
||||
"native-quantity.cjs",
|
||||
"native-wonga.js",
|
||||
"native-jungsang.js",
|
||||
@@ -94,6 +95,9 @@ const allowed = new Set([
|
||||
"native-new-estimate.js",
|
||||
"native-book.js",
|
||||
"native-data-update.js",
|
||||
"native-quantity-table.js",
|
||||
"native-quantity-list.js",
|
||||
"native-actions-more.js",
|
||||
"native-wonga-tools.js",
|
||||
"native-estimate-print.js",
|
||||
"native-xml-check.js",
|
||||
@@ -271,9 +275,17 @@ const server = http.createServer(async (req, res) => {
|
||||
mainData = {
|
||||
name: "Main",
|
||||
tables: Object.fromEntries(keys.map((k) => [k, project.tables[k] || []])),
|
||||
quantitySheets: project.quantitySheets || [],
|
||||
};
|
||||
}
|
||||
reply(res, 200, mainData);
|
||||
// 수량 불러오기(TFRMMULLANG btnMulLoad)는 수량산출서만
|
||||
reply(
|
||||
res,
|
||||
200,
|
||||
url.searchParams.get("part") === "quantity"
|
||||
? { quantitySheets: mainData.quantitySheets }
|
||||
: { name: mainData.name, tables: mainData.tables },
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET" && url.pathname.startsWith("/api/book")) {
|
||||
|
||||
Reference in New Issue
Block a user