Merge remote-tracking branch 'origin/dev' into sub_laptop_3
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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)} 아님")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 == []
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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")
|
||||
@@ -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 실행 비교는 하지 못했다.
|
||||
|
||||
|
||||
@@ -20,6 +20,16 @@
|
||||
|
||||
단가산출 화면의 `편집 → 산출근거 문구 바꾸기`는 현재 호표 또는 전체 산출근거에서 문구를 일괄 변경합니다. 결과값·연결 단가 등의 계산 표지는 치환하지 않으며, 자동계산이 켜져 있으면 변경 후 재계산합니다. 실행 취소로 복원할 수 있습니다.
|
||||
|
||||
내역서·일위대가 화면의 `편집 → 단가 고르기`는 자재·노임·기계경비·일위대가·단가산출·견적·실적 자료를 검색어로 찾아 선택 행 아래에 넣거나, 선택 행 하나의 연결을 바꿉니다. 여러 개를 골라 한 번에 넣을 수 있고, 바꿀 때는 변경 전·후를 확인한 뒤 같은 코드를 쓰는 이 표의 다른 행도 함께 바꿀지 고릅니다. 넣거나 바꾼 행만 원천 단가를 다시 읽습니다. 단가산출 산출근거 행은 대상이 아닙니다.
|
||||
|
||||
`파일 → 공내역 파일로 저장`은 원본처럼 내역·일위대가의 금액을 비운 사본을 내려받습니다. 남길 값은 수량만(단가 비움)·단가만(수량 비움)·수량·단가(금액만 비움) 중에서 고르고, 원본 ESTX를 가져온 작업은 ESTX로도 받을 수 있습니다. 사본은 자동계산을 끈 상태로 저장해 열어도 빈칸이 유지되며, 원가계산서 금액도 비웁니다. 현재 작업은 바뀌지 않습니다.
|
||||
|
||||
`편집 → 찾기·다음찾기`(Ctrl+F)는 원본 찾기 창처럼 검색어를 처음부터 또는 현재 위치부터 찾아 해당 행으로 이동합니다. 일위대가·단가산출·기계경비는 찾은 행의 호표로 넘어갑니다. 단가산출에서는 찾은 행의 문구를 한 건씩 바꾸고 다음 위치로 이동하며, 모두 바꾸기는 `산출근거 문구 바꾸기`를 씁니다. 표 위 검색창은 걸러 보기로 그대로 남습니다.
|
||||
|
||||
수량산출 화면의 `산출서 복사`는 현재 산출서를 바로 아래에 복사하고, 내역에 두 번 반영되지 않도록 복사본의 내역 연결을 비웁니다. `산출서 위로`·`아래로`로 순서를 바꿉니다. 산출서 추가·순서 변경이 있는 작업은 ESTX 수정 저장을 거부하고 JSON 저장을 안내합니다.
|
||||
|
||||
수량산출 화면의 수량 항목마다 `끝수 처리`에서 AUTO(산출서 기본)·절사·올림·반올림 0~5자리를 고릅니다. 원본처럼 결과 코드 뒤에 방식과 자리수를 붙이며(`r000001` → `r00000131`), 산출 행의 결과 표지도 함께 바꿉니다. 이 변경이 있는 작업은 ESTX 수정 저장을 거부하고 JSON 저장을 안내합니다.
|
||||
|
||||
내역서 편집 메뉴의 `내역 수량 초기화`는 선택한 상세 행 또는 모든 상세 행의 수량을 0으로 바꿉니다. 비율·합계 자동행은 건드리지 않으며 실행 취소할 수 있습니다.
|
||||
|
||||
원천 자료에 연결된 내역 또는 일위대가 상세 행의 결과비목을 재료·노무·경비로 바꾸면 현재 단가 합계를 유지해 지정한 비목으로 옮깁니다. 비목별 구성으로 되돌릴 때 원천 단가의 합계가 현재 단가와 다르거나 원천이 불명확하면 임의로 복원하지 않고 오류를 표시합니다. 자동계산이 꺼져 있으면 금액은 자동 재계산을 켤 때까지 유지합니다.
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
})();
|
||||
@@ -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;
|
||||
|
||||
@@ -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" : ""),
|
||||
|
||||
@@ -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 };
|
||||
})();
|
||||
@@ -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 };
|
||||
})();
|
||||
@@ -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 };
|
||||
})();
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user