Merge remote-tracking branch 'origin/dev' into sub_laptop_3

This commit is contained in:
2026-11-11 15:22:41 +09:00
25 changed files with 1306 additions and 174 deletions
@@ -283,6 +283,12 @@ def save(batch: list[dict], confirmed: bool = False) -> dict:
"""`/save` — 로직 정본 줄 고침은 `confirmed`(「원문 개정 반영」) 없으면 409(`Store_Revise`) ·
자체 파일 새 줄(키 없음)은 서버가 칸을 잡고 GX 다음 키 — {files, added, warnings}."""
revise.guard(batch, confirmed)
for part in batch: # 별칭(815-2) = 글 그대로 · 앞뒤 빈칸만 뗌 · 빈 글이면 칸 없음
for ch in part.get("changes") or []:
row = ch.get("row")
if isinstance(row, dict) and "별칭" in row:
text = str(row["별칭"] or "").strip()
row.update(별칭=text) if text else row.pop("별칭")
fresh = False
for part in batch:
if part.get("file") != OWN:
+28 -21
View File
@@ -37,6 +37,9 @@ const MARKET = "재료_자재품목.json";
const LINKED = "재료_품셈재료.json";
const JOB = ["인력.json"];
const NOTE = "비고"; // 끝 열(인력은 준용 앞) · 줄에 없으면 빈 칸 · 눌러 적음
// 별칭(815-2 · 요구 764) = 비고 앞 한 열 · 콤마로 여러 개 · 빈 글이면 칸을 지움 · 이 넷만
const ALIAS = "별칭";
const ALIASED = ["인력.json", "기계.json", MARKET, "재료_유가전력.json"];
const FALLBACK: Row = { 키: "", 원문번호: "", 이름: "", 규격: "", 단위: "", 값: null, 출처: "" };
/** 새 줄 바탕 — 본 줄과 같은 칸 · 글 칸은 "" · 수·값 칸은 null. */
@@ -44,16 +47,16 @@ function blank(sample: Row): Row {
const out: Row = {};
for (const [k, v] of Object.entries(sample)) {
if (v && typeof v === "object" && !Array.isArray(v)) out[k] = blank(v as Row);
else out[k] = typeof v === "string" && k !== "값" ? "" : null;
else if (k !== ALIAS) out[k] = typeof v === "string" && k !== "값" ? "" : null;
}
return out;
}
/** 비고 칸 고침 — 빈 글이면 칸을 지움 */
function withNote(row: Row, text: string): Row {
/** 비고 · 별칭 칸 고침 — 빈 글이면 칸을 지움(별칭은 앞뒤 빈칸 뗌) */
function withNote(row: Row, text: string, col = NOTE): Row {
const out = { ...row };
if (text.trim()) out[NOTE] = text;
else delete out[NOTE];
if (text.trim()) out[col] = col === ALIAS ? text.trim() : text;
else delete out[col];
return out;
}
@@ -117,8 +120,9 @@ export function renderRows(
const cols: string[] = [];
for (const row of [...adds, ...d.rows]) {
for (const k of Object.keys(flatten(row)))
if (!hidden(file, k) && k !== NOTE && !cols.includes(k)) cols.push(k);
if (!hidden(file, k) && k !== NOTE && k !== ALIAS && !cols.includes(k)) cols.push(k);
}
const aliased = ALIASED.includes(file);
const heads = (HEADS[file] ?? []).map((h) =>
el("th", { text: h.startsWith("M01_") ? L(h as "M01_PriceCol") : h }),
);
@@ -127,6 +131,7 @@ export function renderRows(
el("th"),
...cols.map((c) => el("th", { text: c })),
...(JOB.includes(file) ? [] : heads),
...(aliased ? [el("th", { text: ALIAS })] : []),
el("th", { text: NOTE }),
...(JOB.includes(file) ? heads : []),
],
@@ -145,12 +150,13 @@ export function renderRows(
}),
);
}
tr.append(
buildCell(show(row[NOTE]), {
changed: true,
onEdit: (text) => setAdd(file, i, withNote(row, text)),
}),
);
for (const col of aliased ? [ALIAS, NOTE] : [NOTE])
tr.append(
buildCell(show(row[col]), {
changed: true,
onEdit: (text) => setAdd(file, i, withNote(row, text, col)),
}),
);
if (JOB.includes(file)) tr.append(el("td"));
body.append(tr);
});
@@ -186,15 +192,16 @@ export function renderRows(
? undefined
: (next: Row): void => editRow(file, d.version, key, row, next);
if (file === LINKED) tr.append(linkedCell(row, cur, put));
tr.append(
buildCell(show(cur[NOTE]), {
changed: !same(cur[NOTE], row[NOTE]),
onEdit:
deleted || !isScalar(row[NOTE])
? undefined
: (text) => editRow(file, d.version, key, row, withNote(cur, text)),
}),
);
for (const col of aliased ? [ALIAS, NOTE] : [NOTE])
tr.append(
buildCell(show(cur[col]), {
changed: !same(cur[col], row[col]),
onEdit:
deleted || !isScalar(row[col])
? undefined
: (text) => editRow(file, d.version, key, row, withNote(cur, text, col)),
}),
);
if (JOB.includes(file)) tr.append(wageCell(row, cur, put));
body.append(tr);
}
@@ -149,6 +149,11 @@ COLS = {
),
}
COLS["한국은행환율"] = COLS["유가전력"]
ALIAS = "별칭" # 요소 줄 덧칸(815-2) — 열 한 벌 밖 · 있으면 빈 글 아님
def _cols_of(row: dict) -> tuple:
return tuple(k for k in row if k != ALIAS)
def _detail(name: str) -> str:
@@ -310,7 +315,9 @@ def check_form(name: str, data: dict) -> list[str]:
out.append(f"{where} · 출처 모양 「{row['출처']}」")
if data.get("그룹") == "인력":
out += _check_labor(where, row)
if cols and tuple(row) != cols:
if ALIAS in row and not (isinstance(row[ALIAS], str) and row[ALIAS].strip()):
out.append(f"{where} · 「{ALIAS}」 이 빈 글 · 글 아님(빈 칸이면 칸을 두지 않음)")
if cols and _cols_of(row) != cols:
out.append(f"{where} · 열이 한 벌이 아님 — 「{' · '.join(cols)}」")
if kind == "품셈재료":
out += _check_linked(where, row)
@@ -328,7 +335,7 @@ def check_form(name: str, data: dict) -> list[str]:
def _check_labor(where: str, row: dict) -> list[str]:
"""인력 줄 — 열 한 벌(차례까지 같음) · 상태 값 · 옛이름 목록."""
out = []
if tuple(row) != LABOR_COLS:
if _cols_of(row) != LABOR_COLS:
out.append(f"{where} · 열이 한 벌이 아님 — 「{' · '.join(LABOR_COLS)}」")
if row.get("상태") not in LABOR_STATUS:
out.append(f"{where} · 상태 「{row.get('상태')}」 — {' · '.join(LABOR_STATUS)} 아님")
+32 -18
View File
@@ -12574,7 +12574,8 @@
"조종원": "건설품셈 공통 8-4-4",
"조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1048"
},
"비고": "조종원직종 원문 이름: 건설기계운전사 · 주연료종류: 원문 글을 가름"
"비고": "조종원직종 원문 이름: 건설기계운전사 · 주연료종류: 원문 글을 가름",
"별칭": "아스팔트페이버(피니셔)"
},
{
"키": "EQ000193",
@@ -12635,7 +12636,8 @@
"조종원": "건설품셈 공통 8-4-4",
"조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1048"
},
"비고": "조종원직종 원문 이름: 건설기계운전사 · 주연료종류: 원문 글을 가름"
"비고": "조종원직종 원문 이름: 건설기계운전사 · 주연료종류: 원문 글을 가름",
"별칭": "아스팔트페이버(피니셔)"
},
{
"키": "EQ000194",
@@ -16031,9 +16033,8 @@
"조종원직종": "건설품셈 공통 8-1-3 5"
},
"비고": "규격 단위: (m/m) · 주연료종류: 원문 글을 가름",
"값원문": {
"8-4-5 주연료": "휘발유1.0"
}
"값원문": { "8-4-5 주연료": "휘발유1.0" },
"별칭": "바이브레이트 봉상후렉시블"
},
{
"키": "EQ000249",
@@ -25212,7 +25213,8 @@
"조종원": "건설품셈 공통 8-4-7",
"조종원직종": "건설품셈 공통 8-1-3 5"
},
"비고": "주연료종류: 원문 글을 가름"
"비고": "주연료종류: 원문 글을 가름",
"별칭": "워터제트"
},
{
"키": "EQ000401",
@@ -26734,7 +26736,8 @@
"조종원": "건설품셈 공통 8-4-8",
"조종원직종": "건설품셈 공통 8-1-3 5"
},
"비고": "주연료종류: 원문 글을 가름"
"비고": "주연료종류: 원문 글을 가름",
"별칭": "버킷준설기"
},
{
"키": "EQ000426",
@@ -31131,7 +31134,8 @@
"조종원": "건설품셈 공통 8-4-8",
"조종원직종": "건설품셈 공통 8-1-3 5"
},
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름"
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름",
"별칭": "디젤 엔진"
},
{
"키": "EQ000498",
@@ -31192,7 +31196,8 @@
"조종원": "건설품셈 공통 8-4-8",
"조종원직종": "건설품셈 공통 8-1-3 5"
},
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름"
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름",
"별칭": "디젤 엔진"
},
{
"키": "EQ000499",
@@ -31253,7 +31258,8 @@
"조종원": "건설품셈 공통 8-4-8",
"조종원직종": "건설품셈 공통 8-1-3 5"
},
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름"
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름",
"별칭": "디젤 엔진"
},
{
"키": "EQ000500",
@@ -31314,7 +31320,8 @@
"조종원": "건설품셈 공통 8-4-8",
"조종원직종": "건설품셈 공통 8-1-3 5"
},
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름"
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름",
"별칭": "디젤 엔진"
},
{
"키": "EQ000501",
@@ -31375,7 +31382,8 @@
"조종원": "건설품셈 공통 8-4-8",
"조종원직종": "건설품셈 공통 8-1-3 5"
},
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름"
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름",
"별칭": "디젤 엔진"
},
{
"키": "EQ000502",
@@ -31436,7 +31444,8 @@
"조종원": "건설품셈 공통 8-4-8",
"조종원직종": "건설품셈 공통 8-1-3 5"
},
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름"
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름",
"별칭": "디젤 엔진"
},
{
"키": "EQ000503",
@@ -31497,7 +31506,8 @@
"조종원": "건설품셈 공통 8-4-8",
"조종원직종": "건설품셈 공통 8-1-3 5"
},
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름"
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름",
"별칭": "디젤 엔진"
},
{
"키": "EQ000504",
@@ -31558,7 +31568,8 @@
"조종원": "건설품셈 공통 8-4-8",
"조종원직종": "건설품셈 공통 8-1-3 5"
},
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름"
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름",
"별칭": "디젤 엔진"
},
{
"키": "EQ000505",
@@ -31619,7 +31630,8 @@
"조종원": "건설품셈 공통 8-4-8",
"조종원직종": "건설품셈 공통 8-1-3 5"
},
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름"
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름",
"별칭": "디젤 엔진"
},
{
"키": "EQ000506",
@@ -31680,7 +31692,8 @@
"조종원": "건설품셈 공통 8-4-8",
"조종원직종": "건설품셈 공통 8-1-3 5"
},
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름"
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름",
"별칭": "디젤 엔진"
},
{
"키": "EQ000507",
@@ -31741,7 +31754,8 @@
"조종원": "건설품셈 공통 8-4-8",
"조종원직종": "건설품셈 공통 8-1-3 5"
},
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름"
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름",
"별칭": "디젤 엔진"
},
{
"키": "EQ000508",
@@ -1,124 +0,0 @@
{
"줄": {
"아스팔트페이버(피니셔)||시간": {
"원래": {
"품명": "아스팔트페이버(피니셔)",
"규격": "",
"단위": "hr"
},
"갈래": "짝",
"짝": [
{
"갈래": "기계",
"키": "EQ000193",
"입력값": {},
"배": 1,
"몫": null
}
],
"엑셀조항": [],
"누가": {
"id": "요구735-5·736",
"이름": "사용자 승인(시스템 관리자)"
},
"때": "2026-11-09T11:13:18Z",
"처음파일": null
},
"디젤엔진||시간": {
"원래": {
"품명": "디젤 엔진",
"규격": "",
"단위": "hr"
},
"갈래": "짝",
"짝": [
{
"갈래": "기계",
"키": "EQ000500",
"입력값": {},
"배": 1,
"몫": null
}
],
"엑셀조항": [],
"누가": {
"id": "요구735-5·736",
"이름": "사용자 승인(시스템 관리자)"
},
"때": "2026-11-09T11:13:18Z",
"처음파일": null
},
"워터제트||시간": {
"원래": {
"품명": "워터제트",
"규격": "",
"단위": "hr"
},
"갈래": "짝",
"짝": [
{
"갈래": "기계",
"키": "EQ000400",
"입력값": {},
"배": 1,
"몫": null
}
],
"엑셀조항": [],
"누가": {
"id": "요구735-5·736",
"이름": "사용자 승인(시스템 관리자)"
},
"때": "2026-11-09T11:13:18Z",
"처음파일": null
},
"버킷준설기||시간": {
"원래": {
"품명": "버킷준설기",
"규격": "",
"단위": "hr"
},
"갈래": "짝",
"짝": [
{
"갈래": "기계",
"키": "EQ000425",
"입력값": {},
"배": 1,
"몫": null
}
],
"엑셀조항": [],
"누가": {
"id": "요구735-5·736",
"이름": "사용자 승인(시스템 관리자)"
},
"때": "2026-11-09T11:13:18Z",
"처음파일": null
},
"바이브레이트|봉상후렉시블|시간": {
"원래": {
"품명": "바이브레이트",
"규격": "봉상후렉시블",
"단위": "hr"
},
"갈래": "짝",
"짝": [
{
"갈래": "기계",
"키": "EQ000248",
"입력값": {},
"배": 1,
"몫": null
}
],
"엑셀조항": [],
"누가": {
"id": "요구735-5·736",
"이름": "사용자 승인(시스템 관리자)"
},
"때": "2026-11-09T11:13:18Z",
"처음파일": null
}
}
}
+104
View File
@@ -0,0 +1,104 @@
"""PLAN 815-2 — M01 요소 「별칭」 한 열(요구 764 · 765).
① 별칭 칸 저장 = 그 줄만 바뀜 · 앞뒤 빈칸 뗌 · 빈 글이면 칸 없음(임시 사본 폴더만)
② M01 길은 시스템 관리자만(아니면 403)
③ 마스터 기계 별칭 다섯 → 기계 줄 `별칭` · 옛 장부 파일 걷음
"""
from __future__ import annotations
import json
import shutil
from pathlib import Path
import pytest
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
from common_util import common_util_auth as auth
from M01_MasterData import M01_MasterData_Router as router_module
from M01_MasterData import M01_MasterData_Store as store
ROOT = Path(__file__).resolve().parents[2]
REAL = store.FOLDER
MACHINE = "기계.json"
KEY = "EQ000001"
FIVE = {
"EQ000192": "아스팔트페이버(피니셔)",
"EQ000193": "아스팔트페이버(피니셔)",
**{f"EQ000{n}": "디젤 엔진" for n in range(497, 508)},
"EQ000400": "워터제트",
"EQ000425": "버킷준설기",
"EQ000248": "바이브레이트 봉상후렉시블",
}
@pytest.fixture
def copy(tmp_path: Path, monkeypatch) -> Path:
for path in REAL.glob("*.json"):
if not path.name.startswith("_") or path.name == store.mk.BOOK.name:
shutil.copy(path, tmp_path / path.name)
monkeypatch.setattr(store, "FOLDER", tmp_path)
return tmp_path
def _app(role: str = "SYSTEM_ADMIN") -> TestClient:
app = FastAPI()
app.dependency_overrides[auth.verify_session] = lambda: {
"user_id": 1,
"name": "시험",
"role": role,
"company_id": 1,
}
deps = [Depends(auth.verify_session), Depends(auth.require_system_admin)]
app.include_router(router_module.router, dependencies=deps)
return TestClient(app)
def _save_alias(client: TestClient, text: str) -> None:
one = client.get("/api/m01/rows", params={"file": MACHINE, "q": KEY}).json()
row = next(r for r in one["rows"] if r["키"] == KEY)
part = {
"file": MACHINE,
"version": one["version"],
"changes": [{"op": "edit", "key": KEY, "row": {**row, "별칭": text}}],
}
res = client.post("/api/m01/save", json={"files": [part]})
assert res.status_code == 200, res.text
def _around(text: str) -> tuple[str, str, str]:
"""(그 줄 앞 글, 그 줄 글, 뒤 글)."""
start = text.rfind("{", 0, text.index(f'"키": "{KEY}"'))
_, end = json.JSONDecoder().raw_decode(text, start)
return text[:start], text[start:end], text[end:]
def test_별칭_저장_그_줄만_빈칸이면_칸_없음(copy: Path) -> None:
client = _app()
before = (copy / MACHINE).read_bytes()
_save_alias(client, " 시험별칭A, 시험별칭B ")
after = (copy / MACHINE).read_bytes().decode("utf-8")
row = next(r for r in json.loads(after)["줄"] if r["키"] == KEY)
assert row["별칭"] == "시험별칭A, 시험별칭B" # 글 그대로 · 앞뒤 빈칸만 뗌
old, new = _around(before.decode("utf-8")), _around(after)
assert old[0] == new[0] and old[2] == new[2] # 그 줄 밖 글 · 서식 그대로
assert json.loads(new[1]) == {**json.loads(old[1]), "별칭": "시험별칭A, 시험별칭B"}
_save_alias(client, " ")
last = _around((copy / MACHINE).read_bytes().decode("utf-8"))
assert last[0] == old[0] and last[2] == old[2]
assert json.loads(last[1]) == json.loads(old[1]) # 빈 글 = 칸 없음 = 처음 값
def test_M01_길은_시스템_관리자만() -> None:
for role in ("USER", "COMPANY_ADMIN"):
res = _app(role).post("/api/m01/save", json={"files": []})
assert res.status_code == 403
main = (ROOT / "main.py").read_text(encoding="utf-8")
assert "include_router(m01_master_data_router, dependencies=system_admin_only)" in main
def test_마스터_기계_별칭_다섯_옮김_장부_걷음() -> None:
rows = {r["키"]: r for r in json.loads((REAL / MACHINE).read_text(encoding="utf-8"))["줄"]}
assert {k: r.get("별칭") for k, r in rows.items() if r.get("별칭")} == FIVE
assert not (ROOT / "resources" / "master_template" / "실무자료" / "맞춤.json").exists()
@@ -0,0 +1,28 @@
"""EST 테스트 앱: 처리기가 있는 화면 동작은 모두 단추·메뉴로 부를 수 있어야 함."""
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2] / "tools" / "est-test"
def test_every_handled_action_has_a_trigger():
sources = {
p.name: p.read_text(encoding="utf-8")
for p in [*ROOT.glob("native-*.js"), *ROOT.glob("native-*.cjs")]
}
sources["index.html"] = (ROOT / "index.html").read_text(encoding="utf-8")
handled = set()
for text in sources.values():
handled.update(re.findall(r'name === "([a-z0-9-]+)"', text))
assert handled
unbound = []
for name in sorted(handled):
uses = sum(
len(re.findall(rf'"{re.escape(name)}"', text))
- len(re.findall(rf'name === "{re.escape(name)}"', text))
for text in sources.values()
)
if uses <= 0:
unbound.append(name)
assert unbound == []
+77
View File
@@ -0,0 +1,77 @@
"""EST 테스트 앱 공내역 파일 저장(원본 mnuFileNullSave · PrintGongMethod 0/1/2)."""
import json
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
SAMPLE = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/sample.estx")
LOAD = """
const fs = require('fs'), vm = require('vm');
const ctx = vm.createContext({ structuredClone, console });
vm.runInContext(fs.readFileSync('tools/est-test/native-blank.js', 'utf8'), ctx);
"""
def node(script: str, *args: str):
out = subprocess.check_output(["node", "-e", LOAD + script, *args], cwd=ROOT)
return json.loads(out.decode("utf-8"))
def test_methods_clear_amounts_and_chosen_values_only():
result = node("""
ctx.project = JSON.parse(fs.readFileSync('tools/est-test/sample.json', 'utf8'));
const out = vm.runInContext(`(() => {
const pick = (p) => p.tables.EstMainDat.find((r) => Number(r.GN) > 0 && Number(r.TU) > 0);
const src = pick(project), at = project.tables.EstMainDat.indexOf(src);
const r = (m) => { const b = EstBlank.make(project, m), x = b.tables.EstMainDat[at];
return [x.TC, x.NC, x.TU > 0, x.GN > 0, b.options.AutoCalc, b.name.endsWith('(공내역)'), 'id' in b]; };
const head = EstBlank.make(project, 0).tables.HeadMainDat.every((x) => !Number(x.TC) && !Number(x.TU));
const wonga = EstBlank.make(project, 2).tables.Wonga.every((x) => !Number(x.Cost));
let bad = ''; try { EstBlank.make(project, 3); } catch (e) { bad = e.message; }
return { m0: r(0), m1: r(1), m2: r(2), head, wonga, bad,
untouched: Number(src.TC) > 0 && Number(src.TU) > 0 };
})()`, ctx);
process.stdout.write(JSON.stringify(out));
""")
assert result == {
"m0": [0, 0, False, True, False, True, False],
"m1": [0, 0, True, False, False, True, False],
"m2": [0, 0, True, True, False, True, False],
"head": True,
"wonga": True,
"bad": "공내역 방법을 고르세요.",
"untouched": True,
}
def test_blank_estx_round_trip_keeps_quantities_and_clears_prices():
if not SAMPLE.exists():
pytest.skip("installed EST sample unavailable")
result = node(
"""
const imp = require('./tools/est-test/import-estx.cjs'), ex = require('./tools/est-test/export-estx.cjs');
const project = imp.importEstx(fs.readFileSync(process.argv[1]));
ctx.project = project;
const blank = vm.runInContext('EstBlank.make(project, 0)', ctx);
const back = imp.importEstx(ex.exportEstx(blank));
const rows = (p) => p.tables.EstMainDat.filter((r) => Number(r.GN) > 0);
process.stdout.write(JSON.stringify({
count: rows(back).length === rows(project).length,
quantities: rows(back).every((r, i) => Number(r.GN) === Number(rows(project)[i].GN)),
prices: back.tables.EstMainDat.every((r) => !Number(r.TU) && !Number(r.TC)),
heads: back.tables.HeadMainDat.every((r) => !Number(r.TC)),
sources: back.tables.LaborcDat.length === project.tables.LaborcDat.length,
}));
""",
str(SAMPLE),
)
assert result == {
"count": True,
"quantities": True,
"prices": True,
"heads": True,
"sources": True,
}
+78
View File
@@ -0,0 +1,78 @@
"""EST 테스트 앱 찾기 창: 다음찾기 순서·호표 위치·산출근거 문구."""
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
FILES = [
"native-math.js",
"native-formula.js",
"native-machine.js",
"native-rows.js",
"native-text.js",
"native-quantity.cjs",
"native-wonga.js",
"native-engine.js",
"native-find.js",
]
def run_js(code: str):
runner = f"""
const fs = require('fs'), vm = require('vm');
const ctx = vm.createContext({{ structuredClone, console }});
for (const f of {json.dumps(FILES)})
vm.runInContext(fs.readFileSync('tools/est-test/' + f, 'utf8'), ctx, {{ filename: f }});
ctx.project = JSON.parse(fs.readFileSync('tools/est-test/sample.json', 'utf8'));
process.stdout.write(JSON.stringify(vm.runInContext({json.dumps(code)}, ctx)));
"""
out = subprocess.check_output(["node", "-e", runner], cwd=ROOT)
return json.loads(out.decode("utf-8"))
def test_next_walks_matches_in_order_and_stops_at_end():
result = run_js("""(() => {
const rows = [{Name:'터파기'},{Name:'되메우기'},{Name:'잔토 터파기',Size:'백호'},{Name:'포장'}];
const first = EstFind.next(rows, '터파기', 0);
const second = EstFind.next(rows, '터파기', first + 1);
const end = EstFind.next(rows, '터파기', second + 1);
const spec = EstFind.next(rows, '백호', 0);
const loose = EstFind.next(rows, ' 잔토 ', 0);
let empty = '';
try { EstFind.next(rows, ' ', 0); } catch (e) { empty = e.message; }
return {first, second, end, spec, loose, empty};
})()""")
assert result == {
"first": 0,
"second": 2,
"end": -1,
"spec": 2,
"loose": 2,
"empty": "검색어를 입력하세요.",
}
def test_search_reads_cost_line_text_and_skips_previous_values():
result = run_js("""(() => {
const rows = [
{LineData:'\\x0e콘크리트\\x0e \\x13타설\\x13', Name_:'옛이름'},
{Name:'다른 행'}
];
return {
line: EstFind.next(rows, '타설', 0),
old: EstFind.next(rows, '옛이름', 0)
};
})()""")
assert result == {"line": 0, "old": -1}
def test_group_position_for_head_rows():
result = run_js("""(() => {
const rows = project.tables.HeadMainDat;
const titles = rows.map((r, i) => [i, r]).filter(([, r]) => Number(r.Index) > 0);
const [start, title] = titles[1];
const hit = EstFind.groupOf(rows, start + 1);
return {code: hit.code === title.Code, position: hit.position};
})()""")
assert result == {"code": True, "position": 1}
+136
View File
@@ -0,0 +1,136 @@
"""EST 테스트 앱 단가 고르기: 검색·넣기·바꾸기와 계산 연결."""
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
FILES = [
"native-math.js",
"native-formula.js",
"native-machine.js",
"native-rows.js",
"native-text.js",
"native-quantity.cjs",
"native-wonga.js",
"native-engine.js",
"native-row-match.js",
"native-escalation.js",
"native-progress.js",
"native-business.js",
"native-review.js",
"native-resources.js",
"native-schedule.js",
"native-model.js",
"native-verify.js",
"native-pick.js",
]
def run_js(code: str):
runner = f"""
const fs = require('fs'), vm = require('vm');
const ctx = vm.createContext({{ structuredClone, console }});
for (const f of {json.dumps(FILES)})
vm.runInContext(fs.readFileSync('tools/est-test/' + f, 'utf8'), ctx, {{ filename: f }});
ctx.project = JSON.parse(fs.readFileSync('tools/est-test/sample.json', 'utf8'));
process.stdout.write(JSON.stringify(vm.runInContext({json.dumps(code)}, ctx)));
"""
out = subprocess.check_output(["node", "-e", runner], cwd=ROOT)
return json.loads(out.decode("utf-8"))
def test_search_filters_by_kind_and_all_words():
result = run_js("""(() => {
const labor = project.tables.LaborcDat.find((r) => r.Code && r.Name);
const word = String(labor.Name).replace(/\\s+/g, "").slice(0, 2);
const one = EstPick.search(project, 6, word);
const all = EstPick.search(project, 0, labor.Code);
const none = EstPick.search(project, 6, word + " 없는말없는말");
return {
kinds: [...new Set(one.rows.map((r) => r.kind))],
hit: one.rows.some((r) => r.Code === labor.Code),
exact: all.rows.filter((r) => r.Code === labor.Code).length,
none: none.total,
};
})()""")
assert result == {"kinds": [6], "hit": True, "exact": 1, "none": 0}
def test_insert_links_rows_and_engine_prices_them():
result = run_js("""(() => {
const labor = project.tables.LaborcDat.find((r) => r.Code && Number(r.TC) > 0);
const pick = EstPick.items(project, 6).find((r) => r.Code === labor.Code);
const rows = project.tables.EstMainDat, before = rows.length;
const added = EstPick.insert(project, "EstMainDat", 0, "", [pick, pick]);
const done = EstNative.calculate(EstModel.validate(project));
const row = done.tables.EstMainDat[added[0]];
return {
added, grew: done.tables.EstMainDat.length - before,
link: row.LinkIndex, code: row.Code === labor.Code, name: row.Name === labor.Name,
priced: Number(row.NU) > 0 && Number(row.NU) === Number(pick.price),
};
})()""")
assert result == {
"added": [1, 2],
"grew": 2,
"link": 6,
"code": True,
"name": True,
"priced": True,
}
def test_replace_current_row_or_all_same_code():
result = run_js("""(() => {
const rows = project.tables.EstMainDat;
const idx = rows.findIndex((r) => r.Code && !(Number(r.Index) > 0) && [5, 6, 103, 104].includes(Number(r.LinkIndex)));
const target = rows[idx];
const twin = { ...target, PkID: 999999 };
rows.splice(idx + 1, 0, twin);
const pick = EstPick.items(project, 5).find((r) => r.Code !== target.Code);
const one = EstPick.replace(structuredClone(project), "EstMainDat", idx, "", pick, false);
const copy = structuredClone(project);
const all = EstPick.replace(copy, "EstMainDat", idx, "", pick, true);
return {
one: one.length, all: all.length >= 2,
moved: copy.tables.EstMainDat[idx + 1].Code === pick.Code,
kind: copy.tables.EstMainDat[idx].LinkIndex,
untouched: project.tables.EstMainDat[idx].Code === target.Code,
};
})()""")
assert result == {"one": 1, "all": True, "moved": True, "kind": 5, "untouched": True}
def test_guards_reject_wrong_view_self_reference_and_title_rows():
result = run_js("""(() => {
const out = [];
const tryIt = (fn) => { try { fn(); out.push("ok"); } catch (e) { out.push(e.message.slice(0, 12)); } };
const head = EstPick.items(project, 103)[0];
tryIt(() => EstPick.insert(project, "CostMainDat", 0, "", [head]));
tryIt(() => EstPick.insert(project, "HeadMainDat", 0, head.Code, [head]));
tryIt(() => EstPick.insert(project, "EstMainDat", 0, "", []));
const title = project.tables.HeadMainDat.findIndex((r) => Number(r.Index) > 0);
tryIt(() => EstPick.replace(project, "HeadMainDat", title, "", EstPick.items(project, 6)[0], false));
return out;
})()""")
expected = [
"내역서·일위대가 화면",
"일위대가 자신을 하위",
"넣을 자료를 하나 이상",
"일반 내역 행 하나를",
]
assert len(result) == len(expected)
assert all(message.startswith(head[:8]) for message, head in zip(result, expected))
def test_insert_into_head_group_prices_from_source():
result = run_js("""(() => {
const rows = project.tables.HeadMainDat;
const title = rows.findIndex((r) => Number(r.Index) > 0);
const stuff = EstPick.items(project, 5).find((r) => r.price > 0);
const added = EstPick.insert(project, "HeadMainDat", title, rows[title].Code, [stuff]);
const row = project.tables.HeadMainDat[added[0]];
return { at: added[0] === title + 1, link: row.LinkIndex, unit: Number(row.GU) === stuff.price };
})()""")
assert result == {"at": True, "link": 5, "unit": True}
@@ -0,0 +1,96 @@
"""EST 테스트 앱 수량 항목 끝수 처리(원본 AUTO·절사·올림·반올림 0~5)."""
from test_est_rea_parity import run
SHEET = """const sheet={Code:'N1',Name:'시험',options:{digits:2,roundMode:2},
items:[{Code:'r000000',Name:'가'},{Code:'r000001',Name:'나'}],
rows:[{LineData:' 가 1.2345×1 = \\x121.23\\x12',markers:['r000000']},
{LineData:' 나 2.0001×1 = \\x122\\x12',markers:['r000001']}]};"""
def test_format_renames_item_and_marker_and_changes_result():
result = run(
"math",
"(()=>{"
+ SHEET
+ """
const auto=EstQuantity.calculate(sheet).items[0].GN;
EstQuantity.setResultFormat(sheet,0,3,1);
const up=EstQuantity.calculate(sheet).items[0].GN;
const code=sheet.items[0].Code, marker=sheet.rows[0].markers[0];
EstQuantity.setResultFormat(sheet,0,1,3);
const half=EstQuantity.calculate(sheet).items[0].GN;
EstQuantity.setResultFormat(sheet,0,0);
return {auto,up,code,marker,half,back:sheet.items[0].Code,backMarker:sheet.rows[0].markers[0],
other:sheet.items[1].Code};
})()""",
)
assert result == {
"auto": 1.23,
"up": 1.3,
"code": "r00000031",
"marker": "r00000031",
"half": 1.235,
"back": "r000000",
"backMarker": "r000000",
"other": "r000001",
}
def test_format_rejects_bad_digits_collision_and_unknown_code():
result = run(
"math",
"(()=>{"
+ SHEET
+ """
const out=[];
const t=(fn)=>{try{fn();out.push('ok');}catch(e){out.push(e.message.slice(0,6));}};
t(()=>EstQuantity.setResultFormat(sheet,0,2,6));
sheet.items[1].Code='r00000021'; sheet.rows[1].markers=['r00000021'];
t(()=>EstQuantity.setResultFormat(sheet,0,2,1));
sheet.items.push({Code:'X1'});
t(()=>EstQuantity.setResultFormat(sheet,2,2,1));
return {out,unchanged:sheet.items[0].Code};
})()""",
)
assert result["unchanged"] == "r000000"
assert [m[:2] for m in result["out"]] == ["자리", "같은", "이 "]
def test_new_item_code_skips_base_used_by_formatted_code():
result = run(
"math",
"(()=>{"
+ SHEET
+ """
EstQuantity.setResultFormat(sheet,0,2,3);
return EstQuantity.nextItemCode(sheet);
})()""",
)
assert result == "r000002"
def test_estx_export_refuses_format_change_with_clear_message():
import json
import subprocess
from pathlib import Path
import pytest
source = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx")
if not source.exists():
pytest.skip("installed EST main sample unavailable")
root = Path(__file__).resolve().parents[2]
script = """
const fs=require('fs'), path=process.argv[1];
const imp=require('./tools/est-test/import-estx.cjs'), q=require('./tools/est-test/native-quantity.cjs');
const ex=require('./tools/est-test/export-estx.cjs'), p=imp.importEstx(fs.readFileSync(path));
const used=(s,c)=>s.rows.some(r=>(r.markers||[]).includes(c));
const s=p.quantitySheets.find(s=>s.items.some(i=>/^r\d{6}$/.test(i.Code)&&used(s,i.Code)));
q.setResultFormat(s,s.items.findIndex(i=>/^r\d{6}$/.test(i.Code)&&used(s,i.Code)),1,3);
let message='';
try { ex.exportEstx(p); } catch (e) { message=e.message; }
process.stdout.write(JSON.stringify(message));
"""
out = subprocess.check_output(["node", "-e", script, str(source)], cwd=root)
assert json.loads(out.decode("utf-8")).startswith("수량 항목 끝수 처리 변경은 ESTX")
@@ -0,0 +1,74 @@
"""EST 테스트 앱 수량산출서 복사·순서 이동과 ESTX 저장 거부."""
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")
LOAD = """
const fs = require('fs'), vm = require('vm');
const ctx = vm.createContext({ structuredClone, console });
vm.runInContext(fs.readFileSync('tools/est-test/native-quantity-sheets.js', 'utf8'), ctx);
const run = (code) => vm.runInContext(code, ctx);
"""
def node(script: str, *args: str):
out = subprocess.check_output(["node", "-e", LOAD + script, *args], cwd=ROOT)
return json.loads(out.decode("utf-8"))
def test_copy_inserts_below_with_new_code_and_cleared_links():
result = node("""
ctx.project = { quantitySheets: [
{ Code: 'A', Name: '토공', items: [{ Code: 'r000000', TargetCode: 'D1', TargetKey: 'id:3', LinkCode: 'D1' }], rows: [{ LineData: 'x', markers: ['r000000'] }] },
{ Code: 'B', Name: '포장', items: [], rows: [] },
] };
const first = run("EstQuantitySheets.copy(project, 'A', 'u1')");
const second = run("EstQuantitySheets.copy(project, 'A', 'u1')");
const p = ctx.project;
process.stdout.write(JSON.stringify({
first, second, order: p.quantitySheets.map((s) => s.Code), name: p.quantitySheets[1].Name,
link: p.quantitySheets[1].items[0], original: p.quantitySheets[0].items[0].TargetCode,
rows: p.quantitySheets[1].rows[0].markers,
}));
""")
assert result == {
"first": "Qu1",
"second": "Qu1_2",
"order": ["A", "Qu1_2", "Qu1", "B"],
"name": "토공 복사본",
"link": {"Code": "r000000", "TargetCode": "", "TargetKey": ""},
"original": "D1",
"rows": ["r000000"],
}
def test_move_swaps_and_stops_at_edges():
result = node("""
ctx.project = { quantitySheets: [{ Code: 'A' }, { Code: 'B' }, { Code: 'C' }] };
const out = [run("EstQuantitySheets.move(project, 'B', -1)"), run("EstQuantitySheets.move(project, 'B', -1)"),
run("EstQuantitySheets.move(project, 'C', 1)")];
process.stdout.write(JSON.stringify({ out, order: ctx.project.quantitySheets.map((s) => s.Code) }));
""")
assert result == {"out": [True, False, False], "order": ["B", "A", "C"]}
def test_estx_export_refuses_reordered_sheets():
if not MAIN.exists():
pytest.skip("installed EST main sample unavailable")
result = node(
"""
const imp = require('./tools/est-test/import-estx.cjs'), ex = require('./tools/est-test/export-estx.cjs');
ctx.project = imp.importEstx(fs.readFileSync(process.argv[1]));
run("EstQuantitySheets.move(project, project.quantitySheets[0].Code, 1)");
let message = '';
try { ex.exportEstx(ctx.project); } catch (e) { message = e.message; }
process.stdout.write(JSON.stringify(message));
""",
str(MAIN),
)
assert result.startswith("수량산출서 순서 변경은 ESTX")
+29
View File
@@ -423,6 +423,35 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD
- 백그라운드 상태 감시 및 자동 알림: `tools/est-test/monitor.cjs`를 통해 15초 주기로 `orca.exe` CLI(`shell: false`, timeout 4~5s)를 호출하여 CODEX 워크트리의 안티그래비티 작업 상태와 EST 5175 서버 헬스를 조회하고, 상태 변경 시에만 `monitor.log`에 기록(최대 512KB 순환). 안티그래비티 완료/대기 전이 시 Codex Orchestration Run(`run_cdb1558b9280`)으로 단발성 자동 알림(`status`/`messageId=msg_2c7bca748f91`)이 실제 전달됨을 확인(중복 폭주 방지 플래그 적용, 민감 환경변수 미기록, 재시작/자동지시 없음).
## Codex · EST 독립 테스트 앱 (2026-11-04)
- [ ] 원본 메뉴·단추 대조(CAD 제외 약 880개)에서 나온 핵심 미구현 처리. 대조표 = 작업 폴더 `tmp/est-audit/gap_*.md`.
- [x] 처리기만 있고 단추가 없던 동작 4개 연결 — 레벨 일괄 증가·감소(LEVEL 메뉴), 원가계산서 초기화(원가계산서 메뉴), 설계변경 초기화(설계변경 화면). 처리기마다 단추가 있는지 보는 지킴 시험 `test_est_action_bindings.py` 추가.
- 검증완료: 옛 코드에서 시험 실패(4개 검출)·새 코드 통과, `test_est_row_actions.py` 포함 16개 통과. ORCA 브라우저에서 두 행 레벨 2→3→2 왕복·되돌리기 복원, 원가 초기화 확인창, 당초 없음 차단 문구 확인.
- [x] 단가 고르기(원본 TFRMSERCHPROJECT·TFRMDBLCLICKRTN) — 내역서·일위대가에서 7종 자료 검색·여러 개 선택·선택 행 아래 넣기, 선택 행 연결 바꾸기(변경 전후 확인·같은 코드 전체 변경). 넣거나 바꾼 행만 원천 단가 재조회, 실패 시 되돌림. 자기 일위대가 순환·제목·요율 행 차단.
- 검증완료: `test_est_pick.py` 5개 통과(검색·넣기·단가 반영·바꾸기·차단·일위대가 경로). ORCA 브라우저에서 보통인부 넣기 → 노무단가 172,068 반영, 경유로 바꾸기 → 재료단가 1,376 반영, 되돌리기 2회로 76행·도급액 79,603,000 복원.
- 남음: 단가산출 산출근거(LineData) 행 넣기, 원본 「Main값우선」 선택, 기계경비 상세 행.
- [x] 수량 항목 끝수 처리(원본 TFRMMULLANG mnuAuto·mnuTrunc·mnuUp·mnuRound 0~5) — 결과 코드 뒤 방식+자리수(1 반올림·2 절사·3 올림)로 항목·결과 표지 함께 바꿈, 중복 코드 거부. 새 항목 코드가 끝수 붙은 코드의 앞 7자리와 겹치던 오류 수정. ESTX 수정 저장은 명확한 문구로 거부. 연결 내역코드 칸의 선택 상자가 옆 칸을 덮던 배치 수정.
- 검증완료: `test_est_quantity_format.py` 4개(1.2345 → AUTO 1.23·올림1 1.3·반올림3 1.235·AUTO 복귀, 거부 3종, 코드 생성, 설치 main.estx 저장 거부 문구), 수량·ESTX 저장 시험 포함 50개 통과. ORCA 브라우저에서 선택 상자로 올림 1자리 → 재계산 1.3·코드 r00000031 확인, 샘플 다시 열어 원상 복구.
- 남음: 끝수 변경의 ESTX 저장(원본 항목 코드 키 변경 — 인덱스 재생성 필요).
- [x] 찾기 창(원본 TFRMESTSERCH·TFRMCOSTWORDCHANGE) — 검색어·처음부터/현재위치부터·다음찾기로 행 이동(호표 자동 전환·쪽 이동), 단가산출 한 건씩 바꾸기 후 다음 위치, Ctrl+F. 이전 값(`*_`) 필드는 찾지 않음.
- 검증완료: `test_est_find.py` 3개, 단추·단가 고르기·행 동작 포함 24개 통과. ORCA 브라우저에서 Ctrl+F로 창 열기, 내역 「합판거푸집」 8·15·22·26행 순서 이동, 단가산출 마지막 호표 행 찾기(호표 D0000000006 전환·선택 표시), 바꾸기 후 다음 위치(323행)·되돌리기 원상 확인.
- 남음: 통합검색(TFRMMULTISERCH — 다른 공사·폴더 자료 검색).
- [x] 공내역 파일로 저장(원본 mnuFileNullSaveClick 0x10cdc44 → 0x113cf38) — 디스어셈블 근거: EstMainDat·HeadTitleDat·HeadMainDat의 TC·NC·GC·JC·SC 비움, PrintGongMethod 0 = TU~SU 비움·1 = GN 비움·2 = 금액만. 웹 사본은 자동계산 끔·원가 금액 비움(원본 WongaDat 처리는 미확인). JSON 또는 원본 구조 ESTX로 내려받기.
- 검증완료: `test_est_blank.py` 2개(세 방법·원본 불변·잘못된 방법 거부, 설치 sample.estx 공내역 ESTX 저장·재읽기에서 수량 유지·단가·금액 0·기초자료 유지), 관련 11개 통과. ORCA 브라우저에서 메뉴 → 수량만 남김 → JSON 내려받기(GN 133·TU 0·TC 0), 현재 작업 불변, 사본을 열어 합계 0 유지 확인 후 샘플 복구.
- [x] 수량산출서 복사·순서(원본 TFRMMULLANG 공종복사·붙여넣기) — 현재 산출서 아래에 복사(새 코드·내역 연결 비움), 위로·아래로. 순서 변경 ESTX 저장은 명확한 문구로 거부. 단추 지킴 시험이 `.cjs` 화면 파일도 보도록 넓힘.
- 검증완료: `test_est_quantity_sheets.py` 3개(복사 위치·코드·연결 비움·원본 불변, 끝에서 멈춤, 설치 main.estx 순서 변경 저장 거부), 수량 관련 23개 통과. ORCA 브라우저에서 토공 복사 → 「토공 복사본」 선택·연결 빈칸, 아래로 → 토공·포장·복사본 순서 확인 후 샘플 복구.
- 대조 합계(CAD 제외 882개): 구현 283 · 부분 193 · 없음 273 · 제외 133.
- [ ] 남은 핵심(실무 빈도순):
1. 통합검색 — 저장된 다른 작업에서 단가·호표 찾기.
3. 수량산출서 — 기존 줄 수량 지정·해제, 누계·4방향 합계, 제출용 전체 인쇄.
4. Excel → EST 열 맞춤 가져오기(남의 공내역 Excel).
5. 다른 공사 불러오기 시 딸린 일위·단가산출 함께 복사, 프로젝트 추가(합치기).
6. 낙찰률 전체·범위·공종별 적용.
7. 무관자료 삭제, 데이타 정리(정렬·번호), 품명정리.
8. 원가계산서 — 정산원가계산서, 기타경비·요율 대화, 서식 저장·불러오기, 기성 원가 편집.
9. 묶음 인쇄(설계서 한 벌).
10. 표지·갑지·문서 편집 서식(공사명·위치·개요·금액 붙여넣기, 표).
- 도메인 값이 필요해 사용자 협의 뒤: 산업안전보건관리비 비교, 조달청 표준공사코드, 현재데이타 갱신(단가 DB), 품셈 열람.
- [x] 연차 수수료의 원본 연결값 `LinkIndex_`가 57로 보존된 경우 ESTX 재열기에서 원가 계산에 반영하고, 없는 경우 저장 차단을 유지한다.
- 자체 검증: 설치 sample의 조달수수료 2건을 연차 사본으로 저장하고 관급 수수료 제외 옵션을 켠 상태에서 재열어 원가 전 항목과 인덱스 바이트가 일치함을 확인. 기존 연결값이 없는 경우 저장 거부. 연차·기성·인덱스 관련 통합 회귀 43개 통과. `99`·고정 행·기존 연결값 `57` 조합에만 적용하며 원본 EST 실행 비교는 하지 못했다.
+10
View File
@@ -20,6 +20,16 @@
단가산출 화면의 `편집 → 산출근거 문구 바꾸기`는 현재 호표 또는 전체 산출근거에서 문구를 일괄 변경합니다. 결과값·연결 단가 등의 계산 표지는 치환하지 않으며, 자동계산이 켜져 있으면 변경 후 재계산합니다. 실행 취소로 복원할 수 있습니다.
내역서·일위대가 화면의 `편집 → 단가 고르기`는 자재·노임·기계경비·일위대가·단가산출·견적·실적 자료를 검색어로 찾아 선택 행 아래에 넣거나, 선택 행 하나의 연결을 바꿉니다. 여러 개를 골라 한 번에 넣을 수 있고, 바꿀 때는 변경 전·후를 확인한 뒤 같은 코드를 쓰는 이 표의 다른 행도 함께 바꿀지 고릅니다. 넣거나 바꾼 행만 원천 단가를 다시 읽습니다. 단가산출 산출근거 행은 대상이 아닙니다.
`파일 → 공내역 파일로 저장`은 원본처럼 내역·일위대가의 금액을 비운 사본을 내려받습니다. 남길 값은 수량만(단가 비움)·단가만(수량 비움)·수량·단가(금액만 비움) 중에서 고르고, 원본 ESTX를 가져온 작업은 ESTX로도 받을 수 있습니다. 사본은 자동계산을 끈 상태로 저장해 열어도 빈칸이 유지되며, 원가계산서 금액도 비웁니다. 현재 작업은 바뀌지 않습니다.
`편집 → 찾기·다음찾기`(Ctrl+F)는 원본 찾기 창처럼 검색어를 처음부터 또는 현재 위치부터 찾아 해당 행으로 이동합니다. 일위대가·단가산출·기계경비는 찾은 행의 호표로 넘어갑니다. 단가산출에서는 찾은 행의 문구를 한 건씩 바꾸고 다음 위치로 이동하며, 모두 바꾸기는 `산출근거 문구 바꾸기`를 씁니다. 표 위 검색창은 걸러 보기로 그대로 남습니다.
수량산출 화면의 `산출서 복사`는 현재 산출서를 바로 아래에 복사하고, 내역에 두 번 반영되지 않도록 복사본의 내역 연결을 비웁니다. `산출서 위로`·`아래로`로 순서를 바꿉니다. 산출서 추가·순서 변경이 있는 작업은 ESTX 수정 저장을 거부하고 JSON 저장을 안내합니다.
수량산출 화면의 수량 항목마다 `끝수 처리`에서 AUTO(산출서 기본)·절사·올림·반올림 0~5자리를 고릅니다. 원본처럼 결과 코드 뒤에 방식과 자리수를 붙이며(`r000001` → `r00000131`), 산출 행의 결과 표지도 함께 바꿉니다. 이 변경이 있는 작업은 ESTX 수정 저장을 거부하고 JSON 저장을 안내합니다.
내역서 편집 메뉴의 `내역 수량 초기화`는 선택한 상세 행 또는 모든 상세 행의 수량을 0으로 바꿉니다. 비율·합계 자동행은 건드리지 않으며 실행 취소할 수 있습니다.
원천 자료에 연결된 내역 또는 일위대가 상세 행의 결과비목을 재료·노무·경비로 바꾸면 현재 단가 합계를 유지해 지정한 비목으로 옮깁니다. 비목별 구성으로 되돌릴 때 원천 단가의 합계가 현재 단가와 다르거나 원천이 불명확하면 임의로 복원하지 않고 오류를 표시합니다. 자동계산이 꺼져 있으면 금액은 자동 재계산을 켤 때까지 유지합니다.
+12
View File
@@ -418,6 +418,8 @@ function updateQuantities(project, base, cp949, memory) {
const sheets = project.quantitySheets || [],
originals = base.quantitySheets || [];
if (sheets.length !== originals.length) throw Error("수량산출서 추가·삭제는 지원하지 않습니다.");
if (sheets.some((sheet, i) => sheet.Code !== originals[i].Code))
throw Error("수량산출서 순서 변경은 ESTX 저장을 지원하지 않습니다. JSON으로 저장하세요.");
const rows = project.tables.MulMainDat.map((r) => ({ ...r }));
const titles = (project.tables.MulTitleDat || []).map((r) => ({ ...r }));
const savedSheets = sheets.slice();
@@ -438,6 +440,16 @@ function updateQuantities(project, base, cp949, memory) {
}
if (!row?.Editor?.binary || !same(row.Editor, oldRow.Editor))
throw Error("수량산출서 원본 편집기 데이터 충돌");
const resultBase = (code) => String(code ?? "").match(/^([LrG]\d{6})(?:[123]\d)?$/)?.[1];
if (
originals[i].items.some(
(item) =>
resultBase(item.Code) &&
!sheets[i].items.some((next) => next.Code === item.Code) &&
sheets[i].items.some((next) => resultBase(next.Code) === resultBase(item.Code)),
)
)
throw Error("수량 항목 끝수 처리 변경은 ESTX 저장을 지원하지 않습니다. JSON으로 저장하세요.");
const removed = new Set(
originals[i].items
.filter((item) => !sheets[i].items.some((next) => next.Code === item.Code))
+14 -2
View File
@@ -37,6 +37,10 @@
<script defer src="native-exchange.js"></script>
<script defer src="native-c3r.js"></script>
<script defer src="native-block-actions.js"></script>
<script defer src="native-pick.js"></script>
<script defer src="native-find.js"></script>
<script defer src="native-blank.js"></script>
<script defer src="native-quantity-sheets.js"></script>
<script defer src="native-actions.js"></script>
<script defer src="native-business-actions.js"></script>
<script defer src="native-app.js"></script>
@@ -66,6 +70,7 @@
<button data-action="export-xml">조달청 XML 내보내기</button>
<button data-action="import-xml">조달청 XML 불러오기</button>
<button data-action="export-estx">원본 구조 유지 ESTX 수정 저장</button>
<button data-action="blank-save">공내역 파일로 저장</button>
<button data-action="export-jdl">JDL 기본 표 내보내기</button>
<button data-action="import-jdl">JDL 불러오기</button>
</div>
@@ -87,7 +92,10 @@
><button data-action="select-all-rows">전체 선택</button
><button data-action="block-del">블럭 삭제</button>
<hr />
<button data-action="find">찾기</button>
<button data-action="find">찾기·다음찾기(Ctrl+F)</button>
<button data-action="pick-open">
단가 고르기(자재·노임·기계·일위·단가산출·견적·실적)
</button>
<button data-action="uses-data-search">데이터 사용위치 찾기</button>
<button data-action="replace-cost-words">산출근거 문구 바꾸기</button>
<button data-action="set-cost-line-kind">산출근거 비목 지정/해제</button>
@@ -112,6 +120,9 @@
><button data-action="level" data-level="7">LEVEL 7</button
><button data-action="level" data-level="8">LEVEL 8</button
><button data-action="level" data-level="9">LEVEL 9</button>
<hr />
<button data-action="level-batch-up">선택 행 레벨 일괄 증가</button
><button data-action="level-batch-down">선택 행 레벨 일괄 감소</button>
</div>
</details>
<details>
@@ -128,7 +139,8 @@
<div class="menu">
<button data-view="Wonga">원가계산서 보기</button
><button data-action="template">원가 서식 선택</button
><button data-action="settings">자릿수·도급액 고정</button>
><button data-action="settings">자릿수·도급액 고정</button
><button data-action="reset-wonga">원가계산서 초기화</button>
</div>
</details>
<details>
+1 -1
View File
@@ -227,7 +227,7 @@ const EstActions = (() => {
EstApp.calculate();
EstApp.cache();
EstApp.render();
} else if (name === "find") EstApp.byId("search").focus();
} else if (name === "find") EstFind.handle(name, EstApp.state);
else if (name === "level") {
if (!selected().length) throw Error("레벨을 바꿀 행을 선택하세요.");
const level = Number(node.dataset.level);
+79
View File
@@ -0,0 +1,79 @@
// 원본 공내역 파일로저장(TfrmEstMain.mnuFileNullSaveClick 0x10cdc44 → 0x113cf38).
// 내역·일위대가 제목·일위대가 상세의 금액(TC·NC·GC·JC·SC)을 비우고, 공람 방법(PrintGongMethod)에 따라
// 0 = 단가(TU·NU·GU·JU·SU)도 비움(수량만 남김) · 1 = 수량(GN)을 비움(단가만 남김) · 2 = 금액만 비움.
const EstBlank = (() => {
const tables = ["EstMainDat", "HeadTitleDat", "HeadMainDat"];
const amounts = ["TC", "NC", "GC", "JC", "SC"];
const units = ["TU", "NU", "GU", "JU", "SU"];
const methods = [
[0, "수량만 남김(단가·금액 비움)"],
[1, "단가만 남김(수량·금액 비움)"],
[2, "수량·단가 남김(금액만 비움)"],
];
function make(project, method) {
if (![0, 1, 2].includes(method)) throw Error("공내역 방법을 고르세요.");
const blank = structuredClone(project);
const clear =
method === 0 ? [...amounts, ...units] : method === 1 ? [...amounts, "GN"] : amounts;
for (const table of tables)
for (const row of blank.tables[table] || [])
for (const key of clear) if (row[key] != null) row[key] = 0;
// 금액을 비운 원가계산서가 옛 합계를 보이지 않도록 원가 금액도 비운다(원본 WongaDat 처리 확인 전 웹 보조).
for (const row of blank.tables.Wonga || []) if (row.Cost != null) row.Cost = 0;
delete blank.id;
blank.name = `${project.name || "내역서"} (공내역)`;
blank.options = { ...blank.options, AutoCalc: false, SourceRefresh: false };
blank.calculation = { errors: [], warnings: [], direct: {}, total: 0 };
blank.blankMethod = method;
return blank;
}
function open(state) {
const project = state.project;
EstActions.form(
"공내역 파일로 저장",
[
{
name: "method",
label: "남길 값",
value: Number(project.printSettings?.PrintGongMethod ?? 0),
choices: methods,
},
{
name: "format",
label: "파일 형식",
value: project.originalArchive ? "estx" : "json",
choices: project.originalArchive
? [
["estx", "원본 구조 ESTX"],
["json", "웹 작업 JSON"],
]
: [["json", "웹 작업 JSON"]],
},
],
async ({ method, format }) => {
const blank = make(project, Number(method));
if (format === "estx") {
const response = await fetch("/api/export-estx", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(blank),
});
if (!response.ok) throw Error((await response.json()).error);
EstApp.download(`${blank.name}.estx`, await response.blob(), "application/octet-stream");
} else EstApp.download(`${blank.name}.estweb.json`, JSON.stringify(blank, null, 2));
state.notice = "공내역 파일을 내려받았습니다. 현재 작업은 바뀌지 않았습니다.";
EstApp.render();
},
);
}
function handle(name, state) {
if (name !== "blank-save") return false;
open(state);
return true;
}
return { methods, make, handle };
})();
+7 -1
View File
@@ -597,7 +597,13 @@ const EstBusinessActions = (() => {
});
},
);
} else if (EstBlockActions.handle(name, state)) {
} else if (
EstBlockActions.handle(name, state) ||
(typeof EstPick !== "undefined" && EstPick.handle(name, state)) ||
(typeof EstFind !== "undefined" && EstFind.handle(name, state)) ||
(typeof EstBlank !== "undefined" && EstBlank.handle(name, state)) ||
(typeof EstQuantitySheets !== "undefined" && EstQuantitySheets.handle(name, state))
) {
EstApp.render();
} else if (name === "load-contract" || name === "load-bid") {
purpose = name;
+1
View File
@@ -92,6 +92,7 @@ const EstExtra = (() => {
button("recalculate-original", "당초 단가 재계산") +
button("bid-rates", "낙찰률 적용") +
button("restore-bid", "당초단가(100%) 복원") +
button("reset-change", "설계변경 초기화") +
button("change-prev", "이전", state.changePage === 0 ? "disabled" : "") +
`<span>${state.changePage + 1} / ${page.pages}쪽 · ${page.total}행</span>` +
button("change-next", "다음", state.changePage + 1 >= page.pages ? "disabled" : ""),
+121
View File
@@ -0,0 +1,121 @@
// 원본 찾기(TFRMESTSERCH: 검색어·처음부터/현재위치부터·다음찾기)와
// 산출근거 바꾸기(TFRMCOSTWORDCHANGE: 다음찾기·바꾸기)를 옮긴 찾기 창. 모두 바꾸기는 기존 `산출근거 문구 바꾸기`.
const EstFind = (() => {
const grouped = ["HeadMainDat", "CostMainDat", "MachMainDat"];
const fold = (value) =>
String(value ?? "")
.normalize("NFKC")
.toLowerCase();
function text(row) {
return Object.entries(row)
.filter(([key, value]) => typeof value === "string" && !key.endsWith("_"))
.map(([key, value]) => (key === "LineData" ? EstText.clean(value) : value))
.join("\n");
}
function next(rows, query, from = 0) {
const q = fold(query).trim();
if (!q) throw Error("검색어를 입력하세요.");
for (let index = Math.max(0, from); index < rows.length; index++)
if (fold(text(rows[index])).includes(q)) return index;
return -1;
}
function groupOf(rows, index) {
for (const [code, group] of EstNative.groups(rows))
if (index >= group.start && index < group.start + group.rows.length)
return { code, position: index - group.start };
return { code: "", position: index };
}
function open(state) {
const rows = state.project.tables[state.view];
if (!Array.isArray(rows)) throw Error("표 화면에서 찾기를 사용하세요.");
state.find ||= { query: "", replacement: "", from: "current" };
const { esc } = EstApp,
cost = state.view === "CostMainDat";
EstApp.showDialog(
"찾기(Ctrl+F)",
`<div class="form-fields"><label>검색어<input name="find-query" value="${esc(state.find.query)}"></label>${cost ? `<label>바꿀 내용<input name="find-replacement" value="${esc(state.find.replacement)}"></label>` : ""}<label>시작<select name="find-from"><option value="current" ${state.find.from === "current" ? "selected" : ""}>현재위치부터 검색</option><option value="first" ${state.find.from === "first" ? "selected" : ""}>처음부터 검색</option></select></label></div><button data-action="find-next">다음찾기</button>${cost ? ' <button data-action="find-replace">바꾸기</button>' : ""}<p id="find-result" role="status"></p>`,
);
document.querySelector('#dialog-content [name="find-query"]').focus();
}
function read(state) {
const value = (name) => document.querySelector(`#dialog-content [name="${name}"]`)?.value;
state.find.query = value("find-query") ?? "";
state.find.replacement = value("find-replacement") ?? state.find.replacement;
state.find.from = value("find-from") || "current";
}
function show(state, index) {
const rows = state.project.tables[state.view],
{ code, position } = grouped.includes(state.view)
? groupOf(rows, index)
: { code: state.group, position: index };
state.group = code;
state.query = "";
EstApp.byId("search").value = "";
state.page = Math.floor(position / 100);
state.selection = new Set([index]);
EstApp.render();
}
function findNext(state) {
read(state);
const rows = state.project.tables[state.view],
start =
state.find.from === "first"
? 0
: Math.max(-1, ...[...state.selection].map(Number).filter(Number.isFinite)) + 1;
const index = next(rows, state.find.query, start);
const result = EstApp.byId("find-result");
if (index < 0) {
result.textContent = "끝까지 찾았습니다. 더 찾을 항목이 없습니다.";
return;
}
state.find.from = "current";
document.querySelector('#dialog-content [name="find-from"]').value = "current";
show(state, index);
result.textContent = `${index + 1}번째 행을 찾았습니다.`;
}
function replaceCurrent(state) {
read(state);
if (state.view !== "CostMainDat") throw Error("단가산출 화면에서 바꾸기를 사용하세요.");
const rows = state.project.tables.CostMainDat,
index = [...state.selection][0];
if (state.selection.size !== 1 || !rows[index]) throw Error("먼저 다음찾기로 행을 찾으세요.");
const updates = EstText.replaceWords(
[{ row: rows[index], index }],
state.find.query,
state.find.replacement,
);
if (!updates.length) throw Error("이 행에는 바꿀 문구가 없습니다. 다음찾기를 누르세요.");
EstApp.change((project) => (project.tables.CostMainDat[index].LineData = updates[0].value));
findNext(state);
}
function handle(name, state) {
if (name === "find") open(state);
else if (name === "find-next") findNext(state);
else if (name === "find-replace") replaceCurrent(state);
else return false;
return true;
}
if (typeof document !== "undefined" && document.addEventListener)
document.addEventListener("keydown", (event) => {
if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== "f") return;
event.preventDefault();
try {
open(EstApp.state);
} catch (error) {
EstApp.state.notice = error.message;
EstApp.render();
}
});
return { text, next, groupOf, handle };
})();
+238
View File
@@ -0,0 +1,238 @@
// 원본 TFRMSERCHPROJECT(검색어·여러개선택·동시검색·적용)와 TFRMDBLCLICKRTN(데이타 변경 확인)을 옮긴 단가 고르기.
// 내역서·일위대가 행에 기초자료를 골라 넣거나 선택 행의 연결을 바꾼다. 단가산출 산출근거(LineData)는 대상이 아니다.
const EstPick = (() => {
const kinds = [
[5, "StuffcDat", "자재"],
[6, "LaborcDat", "노임"],
[105, "MachTitleDat", "기계경비"],
[103, "HeadTitleDat", "일위대가"],
[104, "CostTitleDat", "단가산출"],
[101, "EstiMateDat", "견적"],
[21, "SilJukDat", "실적"],
];
const views = ["EstMainDat", "HeadMainDat"];
const limit = 300;
const text = (value) =>
String(value ?? "")
.normalize("NFKC")
.toLowerCase()
.replace(/\s+/g, "");
const amount = (row) => {
const total = Number(row.TC) || 0;
return total || ["NC", "GC", "JC", "SC"].reduce((sum, key) => sum + (Number(row[key]) || 0), 0);
};
function items(project, kind) {
const list = [];
for (const [k, table, label] of kinds) {
if (kind && k !== kind) continue;
for (const row of project.tables[table] || [])
if (row.Code && Number(row.Index ?? 1) >= 0)
list.push({
kind: k,
label,
Code: row.Code,
Name: row.Name ?? "",
Size: row.Size ?? "",
Unite: row.Unite ?? "",
price: amount(row),
});
}
return list;
}
function search(project, kind, query) {
const words = String(query || "")
.split(/\s+/)
.map(text)
.filter(Boolean);
const found = items(project, kind).filter((item) => {
const haystack = text(`${item.Code} ${item.Name} ${item.Size}`);
return words.every((word) => haystack.includes(word));
});
return { rows: found.slice(0, limit), total: found.length };
}
function guard(view, group, picks) {
if (!views.includes(view))
throw Error("내역서·일위대가 화면에서만 단가를 골라 넣을 수 있습니다.");
if (!picks.length) throw Error("넣을 자료를 하나 이상 고르세요.");
if (view === "HeadMainDat" && picks.some((p) => p.kind === 103 && p.Code === group))
throw Error("일위대가 자신을 하위 자료로 넣을 수 없습니다.");
}
function linked(base, pick) {
return {
...base,
Code: pick.Code,
Name: pick.Name,
Size: pick.Size,
Unite: pick.Unite,
LinkIndex: pick.kind,
};
}
function insert(project, view, at, group, picks) {
guard(view, group, picks);
const rows = project.tables[view];
const added = [];
for (const pick of picks) {
const row = linked(EstModel.newRow(view, rows, project), pick);
rows.splice(at + 1 + added.length, 0, row);
added.push(at + 1 + added.length);
}
try {
refresh(project, view, added);
} catch (error) {
rows.splice(at + 1, added.length);
throw error;
}
return added;
}
function refresh(project, view, indices) {
// 내역·일위대가 행은 요청한 행만 원천 단가를 다시 읽는다(행 비목 지정과 같은 경로).
let failures = null;
const done = EstNative.calculate(
EstModel.validate(structuredClone(project)),
view === "HeadMainDat"
? { refreshHeadRows: indices, onHeadRefresh: (errors) => (failures = errors) }
: { refreshEstimateRows: indices, onEstimateRefresh: (errors) => (failures = errors) },
);
if (!failures) throw Error("고른 자료의 단가를 다시 읽지 못했습니다.");
if (failures.length) throw Error(failures.join(" / "));
for (const index of indices)
for (const key of EstMath.units)
project.tables[view][index][key] = done.tables[view][index][key];
}
function replaceable(target) {
if (
!target ||
Number(target.Index) > 0 ||
[54, 55, 57, 58, 61, 62, 67, 68].includes(Number(target.LinkIndex))
)
throw Error("일반 내역 행 하나를 선택하세요. 제목·요율·순공사비 행은 바꿀 수 없습니다.");
}
function replace(project, view, index, group, pick, all) {
guard(view, group, [pick]);
const rows = project.tables[view],
target = rows[index];
replaceable(target);
const old = { kind: Number(target.LinkIndex) || 0, Code: target.Code };
const changed = [],
backup = structuredClone(rows);
rows.forEach((row, i) => {
const same =
i === index ||
(all &&
old.Code &&
row.Code === old.Code &&
Number(row.LinkIndex) === old.kind &&
Number(row.Index ?? 0) <= 0);
if (!same) return;
Object.assign(row, linked({}, pick));
changed.push(i);
});
try {
refresh(project, view, changed);
} catch (error) {
project.tables[view] = backup;
throw error;
}
return changed;
}
function cell(value) {
return EstApp.esc(value);
}
function results(state) {
const pick = state.pick,
{ rows, total } = search(state.project, pick.kind, pick.query);
pick.rows = rows;
const body = rows
.map(
(item, i) =>
`<tr><td><input type="checkbox" name="pick-row" value="${i}"></td><td>${cell(item.label)}</td><td>${cell(item.Code)}</td><td style="text-align:left">${cell(item.Name)}</td><td>${cell(item.Size)}</td><td>${cell(item.Unite)}</td><td style="text-align:right">${EstApp.number(item.price)}</td></tr>`,
)
.join("");
return `<div style="max-height:380px;overflow-y:auto;border:1px solid #ccc"><table class="sheet" style="width:100%;font-size:12px"><thead><tr><th></th><th>구분</th><th>코드</th><th>품명</th><th>규격</th><th>단위</th><th>단가</th></tr></thead><tbody>${body}</tbody></table></div><p>찾은 자료 ${total}건${total > limit ? ` 중 앞 ${limit}건 표시 — 검색어를 더 넣으세요` : ""}</p>`;
}
function open(state) {
if (!views.includes(state.view))
throw Error("내역서·일위대가 화면에서만 단가를 골라 넣을 수 있습니다.");
state.pick ||= { kind: 0, query: "" };
const options = [[0, "전체 동시검색"], ...kinds.map(([k, , label]) => [k, label])]
.map(
([k, label]) =>
`<option value="${k}" ${k === state.pick.kind ? "selected" : ""}>${cell(label)}</option>`,
)
.join("");
EstApp.showDialog(
"단가 고르기",
`<div class="form-fields"><label>자료<select name="pick-kind">${options}</select></label><label>검색어<input name="pick-query" value="${cell(state.pick.query)}"></label></div><button data-action="pick-search">검색</button><div id="pick-results">${results(state)}</div><button data-action="pick-insert">선택 행 아래에 넣기</button> <button data-action="pick-replace">선택 행을 이것으로 바꾸기</button>`,
);
}
function chosen(state) {
return [...document.querySelectorAll('#dialog-content [name="pick-row"]:checked')].map(
(node) => state.pick.rows[Number(node.value)],
);
}
function confirmReplace(target, pick) {
const line = (row) =>
`${row.Name || ""} / ${row.Size || ""} / ${row.Unite || ""}${row.Code ? ` [${row.Code}]` : ""}`;
return confirm(
`데이타 변경 확인\n변경 전: ${line(target)}\n변경 후: ${line(pick)}\n\n변경하시겠습니까?`,
);
}
function handle(name, state) {
if (name === "pick-open") open(state);
else if (name === "pick-search") {
state.pick.kind = Number(document.querySelector('#dialog-content [name="pick-kind"]').value);
state.pick.query = document.querySelector('#dialog-content [name="pick-query"]').value;
EstApp.byId("pick-results").innerHTML = results(state);
} else if (name === "pick-insert") {
const picks = chosen(state),
rows = state.project.tables[state.view];
guard(state.view, state.group, picks);
const group = EstNative.groups(rows).get(state.group);
const at =
[...state.selection].sort((a, b) => a - b).at(-1) ??
(group ? group.start + group.rows.length - 1 : rows.length - 1);
EstApp.byId("dialog").close();
EstApp.change((project) => {
state.selection = new Set(insert(project, state.view, at, state.group, picks));
});
state.notice = `${picks.length}개 자료를 넣었습니다.`;
} else if (name === "pick-replace") {
const picks = chosen(state),
index = [...state.selection][0];
if (picks.length !== 1 || state.selection.size !== 1)
throw Error("바꿀 행 하나와 자료 하나를 고르세요.");
const target = state.project.tables[state.view][index];
guard(state.view, state.group, picks);
replaceable(target);
if (!confirmReplace(target, picks[0])) return true;
const all =
Boolean(target.Code) &&
confirm(
`같은 코드(${target.Code})를 쓰는 이 표의 다른 행도 함께 바꾸시겠습니까?\n[취소] 현재 행만 변경`,
);
EstApp.byId("dialog").close();
let changed = [];
EstApp.change((project) => {
changed = replace(project, state.view, index, state.group, picks[0], all);
});
state.notice = `${changed.length}개 행의 연결을 바꿨습니다.`;
} else return false;
return true;
}
return { kinds, items, search, insert, replace, handle };
})();
+52
View File
@@ -0,0 +1,52 @@
// 원본 수량산출(TFRMMULLANG) 공종 단위 조작 — 공종복사하기·붙여넣기(mnuCopy·mnuPaste)와 순서 이동.
// 복사본은 현재 산출서 바로 아래에 넣고, 내역 이중 반영을 막기 위해 내역 연결을 비운다.
const EstQuantitySheets = (() => {
function copy(project, code, uid) {
const sheets = project.quantitySheets || [],
index = sheets.findIndex((sheet) => sheet.Code === code);
if (index < 0) throw Error("복사할 수량산출서를 고르세요.");
const used = new Set(sheets.map((sheet) => sheet.Code));
let next = `Q${uid}`;
for (let n = 2; used.has(next); n++) next = `Q${uid}_${n}`;
const sheet = structuredClone(sheets[index]);
sheet.Code = next;
sheet.Name = `${sheets[index].Name || sheets[index].Code} 복사본`;
for (const item of sheet.items || []) {
item.TargetCode = "";
item.TargetKey = "";
delete item.LinkCode;
delete item.LinkIndex;
}
sheets.splice(index + 1, 0, sheet);
return next;
}
function move(project, code, step) {
const sheets = project.quantitySheets || [],
index = sheets.findIndex((sheet) => sheet.Code === code),
next = index + step;
if (index < 0 || next < 0 || next >= sheets.length) return false;
[sheets[index], sheets[next]] = [sheets[next], sheets[index]];
return true;
}
function handle(name, state) {
const code = state.quantitySheet;
if (name === "quantity-sheet-copy") {
EstApp.change((project) => {
state.quantitySheet = copy(project, code, EstApp.uid());
}, false);
state.notice = "산출서를 복사했습니다. 복사본의 내역 연결은 비워 두었습니다.";
} else if (name === "quantity-sheet-up" || name === "quantity-sheet-down") {
const step = name === "quantity-sheet-up" ? -1 : 1,
sheets = state.project.quantitySheets || [],
index = sheets.findIndex((sheet) => sheet.Code === code);
if (index < 0 || index + step < 0 || index + step >= sheets.length)
throw Error("더 옮길 수 없습니다.");
EstApp.change((project) => move(project, code, step), false);
} else return false;
return true;
}
return { copy, move, handle };
})();
+70 -5
View File
@@ -315,13 +315,64 @@ 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-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></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><input data-quantity-target="${index}" value="${esc(item.TargetCode ?? item.LinkCode ?? "")}" aria-label="연결 내역코드">${targetSelect(state.project, item, "sheet", index)}</td><td><button data-quantity-delete-item="${index}">삭제</button></td></tr>`).join("")}</tbody></table><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-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><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-quantity-delete-row="${index}">삭제</button></td></tr>`).join("")}</tbody></table>`;
}
// 원본 수량목록 자리수 메뉴(mnuAuto·mnuTrunc·mnuUp·mnuRound): 결과 코드 뒤 방식(1 반올림·2 절사·3 올림)+자리수.
function resultFormat(code) {
const match = String(code ?? "").match(/^([LrG]\d{6})(?:([123])(\d))?$/);
return match
? {
base: match[1],
method: Number(match[2] || 0),
digits: match[3] ? Number(match[3]) : null,
}
: null;
}
function setResultFormat(sheet, index, method, digits) {
const item = sheet.items[index],
parsed = resultFormat(item?.Code);
if (!parsed) throw Error("이 수량 항목은 결과 자리수를 지정할 수 없는 코드입니다.");
if (![0, 1, 2, 3].includes(method)) throw Error("끝수 처리 방식을 고르세요.");
if (method && !(Number.isInteger(digits) && digits >= 0 && digits <= 5))
throw Error("자리수는 0~5만 지정할 수 있습니다.");
const old = item.Code,
code = method ? `${parsed.base}${method}${digits}` : parsed.base;
if (code === old) return code;
if (
sheet.items.some((other, i) => i !== index && other.Code === code) ||
sheet.rows.some((row) => (row.markers || []).includes(code))
)
throw Error(`같은 결과 코드가 이미 있습니다: ${code}`);
item.Code = code;
for (const row of sheet.rows)
if (row.markers) row.markers = row.markers.map((marker) => (marker === old ? code : marker));
return code;
}
function formatSelect(item, index) {
const parsed = resultFormat(item.Code);
if (!parsed) return "";
const current = parsed.method ? `${parsed.method}:${parsed.digits}` : "0";
const options = [["0", "AUTO(산출서 기본)"]];
for (const [method, label] of [
[2, "절사"],
[3, "올림"],
[1, "반올림"],
])
for (let digits = 0; digits <= 5; digits++)
options.push([`${method}:${digits}`, `${label} ${digits}자리`]);
return `<select data-quantity-format="${index}" aria-label="끝수 처리">${options
.map(
([value, label]) =>
`<option value="${value}" ${value === current ? "selected" : ""}>${label}</option>`,
)
.join("")}</select>`;
}
function nextItemCode(sheet) {
const codes = new Set([
...sheet.items.map((item) => item.Code),
...sheet.rows.flatMap((row) => row.markers || []),
]);
const codes = new Set(
[...sheet.items.map((item) => item.Code), ...sheet.rows.flatMap((row) => row.markers || [])]
.filter(Boolean)
.map((code) => resultFormat(code)?.base || code),
);
for (let index = 0; index < 1000000; index++) {
const code = `r${String(index).padStart(6, "0")}`;
if (!codes.has(code)) return code;
@@ -385,6 +436,17 @@ const EstQuantity = (() => {
(sheet.rows[Number(node.dataset.quantityLine)].LineData = EstText.encode(node.value)),
false,
);
if (node.dataset.quantityFormat !== undefined) {
const [method, digits] = node.value.split(":").map(Number);
try {
EstApp.change(() =>
setResultFormat(sheet, Number(node.dataset.quantityFormat), method, digits),
);
} catch (error) {
state.notice = error.message;
EstApp.render();
}
}
if (node.dataset.quantityTarget !== undefined)
EstApp.change(() => {
const item = sheet.items[Number(node.dataset.quantityTarget)];
@@ -613,6 +675,9 @@ const EstQuantity = (() => {
excelUpdates,
targetSelect,
newCode,
resultFormat,
nextItemCode,
setResultFormat,
selector,
render,
install,
+4
View File
@@ -57,6 +57,10 @@ const allowed = new Set([
"native-actions.js",
"native-business-actions.js",
"native-block-actions.js",
"native-pick.js",
"native-find.js",
"native-blank.js",
"native-quantity-sheets.js",
"native-issues.js",
"native-attachments.js",
"native-standard.js",