feat(est-test): Excel 내역 열 맞춰 가져오기

- 원본 엑셀 변환 내역서 탭처럼 시트·행 범위·항목별 열 문자로 남의 Excel 내역을 읽음
- 미리보기, 순공사비 위 추가·바꾸기, 단위·수량 없는 줄은 공종 제목
- 수식 셀은 저장된 계산값 사용, 합계단가만 있는 줄은 비목 배분 없이 거부

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-11 17:05:13 +09:00
co-authored by Claude Opus 5.5
parent ae8d098a10
commit 4876ae1e75
7 changed files with 431 additions and 2 deletions
+134
View File
@@ -0,0 +1,134 @@
"""EST 테스트 앱 Excel 내역 열 맞춰 가져오기(원본 TFRMEXCELTOEST 내역서 탭)."""
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-excel-map.js",
]
TABLE = """const table = [
['공 내 역 서'],
['번호','품명','규격','단위','수량','합계단가','재료단가','노무단가','경비단가','비고'],
['1','토공사'],
['1.1','터파기','백호 0.7㎥','㎥','1,200',null,'0','3500','1200','현장'],
[],
['1.2','되메우기','','㎥',800],
['2','포장공'],
['2.1','아스콘포장','t=5cm','㎡',350.5,31000,21000,'7000','3000'],
];
const columns = {Pr:'a',Name:'B',Size:'C',Unite:'D',GN:'E',TU:'F',GU:'G',NU:'H',JU:'I',Bigo:'J'};"""
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_records_map_columns_parse_numbers_and_skip_blank_rows():
result = run_js(
"(() => {"
+ TABLE
+ """
const list = EstExcelMap.records(table, 3, 20, columns);
const errors = [];
const t = (fn) => { try { fn(); errors.push('ok'); } catch (e) { errors.push(e.message.slice(0, 8)); } };
t(() => EstExcelMap.records(table, 3, 8, {GN:'E'}));
t(() => EstExcelMap.records(table, 0, 8, columns));
t(() => EstExcelMap.records(table, 3, 8, {...columns, Name:'B1'}));
t(() => EstExcelMap.records([[null,'x','', '', '1개']], 1, 1, columns));
return { lines: list.map((r) => r.line), first: list[1], errors,
col: [EstExcelMap.columnIndex('AA'), EstExcelMap.columnName(26)] };
})()"""
)
assert result["lines"] == [3, 4, 6, 7, 8]
assert result["first"]["GN"] == 1200 and result["first"]["NU"] == 3500
assert result["first"]["TU"] is None and result["first"]["Pr"] == "1.1"
assert result["col"] == [26, "AA"]
assert [e[:4] for e in result["errors"]] == ["품명 열", "시작행을", "열 문자", "E1: "]
def test_rows_make_headings_details_and_insert_above_net_cost():
result = run_js(
"(() => {"
+ TABLE
+ """
const list = EstExcelMap.records(table, 3, 20, columns);
const { rows, warnings } = EstExcelMap.rows(project, list);
const est = project.tables.EstMainDat, boundary = est.findIndex((r) => Number(r.LinkIndex) === 54);
const ids = new Set([...est.map((r) => r.PkID), ...rows.map((r) => r.PkID)]);
EstExcelMap.insert(project, rows, 'append');
const done = EstNative.calculate(EstModel.validate(project));
const at = done.tables.EstMainDat.findIndex((r) => r.PkID === rows[1].PkID);
const pave = done.tables.EstMainDat.find((r) => r.PkID === rows[4].PkID);
return {
kinds: rows.map((r) => [r.Name, Number(r.Level) || 0]),
warnings, unique: ids.size === est.length,
placed: at === boundary + 1, net: done.tables.EstMainDat[boundary + rows.length].LinkIndex,
dig: [done.tables.EstMainDat[at].NC, done.tables.EstMainDat[at].JC],
pave: [pave.TC, pave.Bigo ?? null],
};
})()"""
)
assert result["kinds"] == [
["토공사", 1],
["터파기", 0],
["되메우기", 0],
["포장공", 1],
["아스콘포장", 0],
]
assert result["warnings"] == []
assert result["unique"] is True
assert result["placed"] is True
assert result["net"] == 54
assert result["dig"] == [4200000, 1440000]
assert result["pave"][0] == 350.5 * 31000
def test_total_price_without_split_is_rejected_and_replace_mode():
result = run_js(
"(() => {"
+ TABLE
+ """
table[3][6] = null; table[3][7] = null; table[3][8] = null; table[3][5] = 4700;
let message = '';
try { EstExcelMap.rows(project, EstExcelMap.records(table, 3, 20, columns)); } catch (e) { message = e.message; }
table[3][5] = null;
const { rows } = EstExcelMap.rows(project, EstExcelMap.records(table, 3, 4, columns));
const before = project.tables.EstMainDat.length,
boundary = project.tables.EstMainDat.findIndex((r) => Number(r.LinkIndex) === 54);
EstExcelMap.insert(project, rows, 'replace');
return { message, length: project.tables.EstMainDat.length === before - boundary + rows.length,
first: project.tables.EstMainDat[0].Name, net: project.tables.EstMainDat[rows.length].LinkIndex };
})()"""
)
assert result["message"].startswith("4행: 합계단가만 있습니다")
assert result["length"] is True
assert result["first"] == "토공사"
assert result["net"] == 54
+3 -1
View File
@@ -441,11 +441,13 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD
- 검증완료: `test_est_quantity_sheets.py` 3개(복사 위치·코드·연결 비움·원본 불변, 끝에서 멈춤, 설치 main.estx 순서 변경 저장 거부), 수량 관련 23개 통과. ORCA 브라우저에서 토공 복사 → 「토공 복사본」 선택·연결 빈칸, 아래로 → 토공·포장·복사본 순서 확인 후 샘플 복구.
- [x] 산출 줄 수량 지정·해제(원본 TFRMMULWORD popSetMulLang·popSetFree) — 드래그한 숫자를 결과 표지로 감싸 새 항목(품명·규격·단위 표지 옮김), 커서 위치 결과 해제·미사용 항목 삭제, 내역 연결 항목 해제 거부. 이 편집은 ESTX 저장을 명확한 문구로 거부(이전에는 「행 삭제」로 잘못 안내).
- 검증완료: `test_est_quantity_sheets.py` 5개(지정 위치·항목·계산 6, 중복·숫자 아님 거부, 해제 원상, 연결 항목 거부, 설치 main.estx 해제 후 저장 거부 문구), 수량 관련 25개 통과. ORCA 브라우저에서 「= 3」 드래그 → 수량 지정 → r000001 잔토처리 ㎥ 3, 커서 두고 지정 해제 → 원래 줄·항목 복귀, 샘플 복구.
- [x] Excel 내역 열 맞춰 가져오기(원본 TFRMEXCELTOEST 내역서 탭 — tvViewEst 열 Code·Pr·Name·Size·GN·Unite·TU·TC·NU·NC·GU·GC·JU·JC·Bigo) — 시트·시작행·끝행·항목별 열 문자, 미리보기, 순공사비 위 추가·바꾸기. 단위·수량 없는 줄 = 공종 제목(Level 1), 금액은 다시 계산, 수식 셀은 저장된 계산값. 합계단가만 있는 줄은 비목 임의 배분 없이 거부.
- 검증완료: `test_est_excel_map.py` 3개(열 문자·쉼표 숫자·빈 줄·잘못된 입력, 제목·상세 구분·PkID 중복 없음·순공사비 위 배치·금액 계산, 합계단가만 거부·바꾸기 모드), 관련 9개 통과. ORCA 브라우저에서 openpyxl로 만든 수식 포함 xlsx를 서버로 읽어 시트 2개 표시·미리보기, 5행(제목 2) 가져오기 → 순공사비 45,293,066 → 61,798,566 확인 후 샘플 복구.
- 남음: 일위대가·단가산출·표준·견적·자재·노임 탭, 원본 「셀부호 밀기·당기기」.
- 대조 합계(CAD 제외 882개): 구현 283 · 부분 193 · 없음 273 · 제외 133.
- [ ] 남은 핵심(실무 빈도순):
1. 통합검색 — 저장된 다른 작업에서 단가·호표 찾기.
3. 수량산출서 — 누계·4방향 합계, 제출용 전체 인쇄.
4. Excel → EST 열 맞춤 가져오기(남의 공내역 Excel).
5. 다른 공사 불러오기 시 딸린 일위·단가산출 함께 복사, 프로젝트 추가(합치기).
6. 낙찰률 전체·범위·공종별 적용.
7. 무관자료 삭제, 데이타 정리(정렬·번호), 품명정리.
+2
View File
@@ -22,6 +22,8 @@
내역서·일위대가 화면의 `편집 → 단가 고르기`는 자재·노임·기계경비·일위대가·단가산출·견적·실적 자료를 검색어로 찾아 선택 행 아래에 넣거나, 선택 행 하나의 연결을 바꿉니다. 여러 개를 골라 한 번에 넣을 수 있고, 바꿀 때는 변경 전·후를 확인한 뒤 같은 코드를 쓰는 이 표의 다른 행도 함께 바꿀지 고릅니다. 넣거나 바꾼 행만 원천 단가를 다시 읽습니다. 단가산출 산출근거 행은 대상이 아닙니다.
`파일 → Excel 내역 열 맞춰 가져오기`는 원본 엑셀→EST 변환처럼 다른 프로그램이나 발주처의 Excel 내역을 읽습니다. 시트·시작행·끝행과 번호·품명·규격·단위·수량·단가 등 항목별 Excel 열 문자를 적고 미리보기로 확인한 뒤, 순공사비 줄 위에 추가하거나 그 위 행을 바꿉니다. 단위와 수량이 없는 줄은 공종 제목이 되고, 금액은 단가×수량으로 다시 계산합니다. 수식 셀은 Excel이 저장해 둔 값을 씁니다. 합계단가만 있고 노무·재료·경비로 나눈 단가가 없으면 비목을 정할 수 없어 가져오지 않습니다.
`파일 → 공내역 파일로 저장`은 원본처럼 내역·일위대가의 금액을 비운 사본을 내려받습니다. 남길 값은 수량만(단가 비움)·단가만(수량 비움)·수량·단가(금액만 비움) 중에서 고르고, 원본 ESTX를 가져온 작업은 ESTX로도 받을 수 있습니다. 사본은 자동계산을 끈 상태로 저장해 열어도 빈칸이 유지되며, 원가계산서 금액도 비웁니다. 현재 작업은 바뀌지 않습니다.
`편집 → 찾기·다음찾기`(Ctrl+F)는 원본 찾기 창처럼 검색어를 처음부터 또는 현재 위치부터 찾아 해당 행으로 이동합니다. 일위대가·단가산출·기계경비는 찾은 행의 호표로 넘어갑니다. 단가산출에서는 찾은 행의 문구를 한 건씩 바꾸고 다음 위치로 이동하며, 모두 바꾸기는 `산출근거 문구 바꾸기`를 씁니다. 표 위 검색창은 걸러 보기로 그대로 남습니다.
+2
View File
@@ -41,6 +41,7 @@
<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-excel-map.js"></script>
<script defer src="native-actions.js"></script>
<script defer src="native-business-actions.js"></script>
<script defer src="native-app.js"></script>
@@ -67,6 +68,7 @@
><button data-action="print">인쇄</button>
<button data-action="export-xlsx">전체 자료 Excel 내보내기</button>
<button data-action="import-xlsx">Excel 표 불러오기</button>
<button data-action="excel-map-open">Excel 내역 열 맞춰 가져오기</button>
<button data-action="export-xml">조달청 XML 내보내기</button>
<button data-action="import-xml">조달청 XML 불러오기</button>
<button data-action="export-estx">원본 구조 유지 ESTX 수정 저장</button>
+2 -1
View File
@@ -602,7 +602,8 @@ const EstBusinessActions = (() => {
(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, node))
(typeof EstQuantitySheets !== "undefined" && EstQuantitySheets.handle(name, state, node)) ||
(typeof EstExcelMap !== "undefined" && EstExcelMap.handle(name, state))
) {
EstApp.render();
} else if (name === "load-contract" || name === "load-bid") {
+287
View File
@@ -0,0 +1,287 @@
// 원본 엑셀데이타를 EST로 변환(TFRMEXCELTOEST) 내역서 탭 — 시트·시작행·끝행과 필드별 열 문자로 남의 Excel 내역을 가져온다.
// 필드는 원본 tvViewEst 열(Code·Pr·Name·Size·GN·Unite·TU·TC·NU·NC·GU·GC·JU·JC·Bigo). 금액은 다시 계산하고 단가만 받는다.
// 단위·수량이 없는 줄은 공종 제목(Level 1). 합계단가만 있고 노무·재료·경비 단가가 없으면 비목을 정할 수 없어 거부한다.
const EstExcelMap = (() => {
const fields = [
["Code", "코드"],
["Pr", "번호"],
["Name", "품명"],
["Size", "규격"],
["GN", "수량"],
["Unite", "단위"],
["TU", "합계단가"],
["TC", "합계금액"],
["NU", "노무단가"],
["NC", "노무금액"],
["GU", "재료단가"],
["GC", "재료금액"],
["JU", "경비단가"],
["JC", "경비금액"],
["Bigo", "비고"],
];
const numeric = new Set(["GN", "TU", "TC", "NU", "NC", "GU", "GC", "JU", "JC"]);
const letters = (value) =>
String(value ?? "")
.trim()
.toUpperCase();
function columnIndex(value) {
const text = letters(value);
if (!text) return -1;
if (!/^[A-Z]{1,3}$/.test(text)) throw Error(`열 문자가 올바르지 않습니다: ${value}`);
const index = [...text].reduce((n, c) => n * 26 + c.charCodeAt(0) - 64, 0) - 1;
if (index >= 16384) throw Error(`열 범위를 넘었습니다: ${value}`);
return index;
}
function columnName(index) {
let name = "";
for (let n = index + 1; n > 0; n = Math.floor((n - 1) / 26))
name = String.fromCharCode(65 + ((n - 1) % 26)) + name;
return name;
}
function number(value, where) {
if (value == null || value === "") return null;
if (typeof value === "number") return value;
const text = String(value).replace(/,/g, "").trim();
if (!text) return null;
const parsed = Number(text);
if (!Number.isFinite(parsed)) throw Error(`${where}: 숫자가 아닙니다 (${value})`);
return parsed;
}
function records(table, start, end, columns) {
if (!Number.isInteger(start) || start < 1) throw Error("시작행을 1 이상으로 입력하세요.");
if (!Number.isInteger(end) || end < start)
throw Error("끝행은 시작행보다 크거나 같아야 합니다.");
const map = Object.entries(columns)
.map(([key, value]) => [key, columnIndex(value)])
.filter(([, index]) => index >= 0);
if (!map.some(([key]) => key === "Name")) throw Error("품명 열을 지정하세요.");
const out = [];
for (let line = start; line <= Math.min(end, table.length); line++) {
const cells = table[line - 1] || [],
record = { line };
for (const [key, index] of map) {
const value = cells[index];
record[key] = numeric.has(key)
? number(value, `${columnName(index)}${line}`)
: value == null
? ""
: String(value).trim();
}
if (map.some(([key]) => record[key] !== "" && record[key] != null)) out.push(record);
}
return out;
}
function rows(project, list) {
const built = [],
rejected = [],
warnings = [];
const table = [...(project.tables.EstMainDat || [])];
for (const record of list) {
if (!record.Name && !record.Size && record.GN == null) continue;
const row = EstModel.newRow("EstMainDat", table, project);
table.push(row);
Object.assign(row, { Name: record.Name || "", Size: record.Size || "", Pr: record.Pr || "" });
if (record.Code) row.Code = record.Code;
if (record.Bigo) row.Bigo = record.Bigo;
if (!record.Unite && record.GN == null) {
Object.assign(row, { Level: 1, GN: null, Unite: "" });
built.push(row);
continue;
}
const split = ["NU", "GU", "JU"].some((key) => record[key] != null);
if (!split && record.TU) {
rejected.push(record.line);
continue;
}
Object.assign(row, {
Unite: record.Unite || "",
GN: record.GN ?? 0,
NU: record.NU ?? 0,
GU: record.GU ?? 0,
JU: record.JU ?? 0,
});
if (record.TU != null && Math.abs(row.NU + row.GU + row.JU - record.TU) > 0.5)
warnings.push(record.line);
built.push(row);
}
if (rejected.length)
throw Error(
`${rejected.slice(0, 5).join(", ")}행${rejected.length > 5 ? ` 외 ${rejected.length - 5}개` : ""}: 합계단가만 있습니다. 노무·재료·경비 단가 열을 지정하거나 합계단가 열을 비우세요.`,
);
return { rows: built, warnings };
}
function insert(project, built, mode) {
const list = project.tables.EstMainDat;
let boundary = list.findIndex((row) => Number(row.LinkIndex) === 54);
if (boundary < 0) boundary = list.length;
if (mode === "replace") {
list.splice(0, boundary, ...built);
return built.length;
}
list.splice(boundary, 0, ...built);
return built.length;
}
// 화면: Excel 시트 XML을 읽어 행 배열로 바꾼다. 수식 셀은 Excel이 저장한 계산값을 쓴다.
function sheetTables(files) {
const parse = (source) => {
if (/<!DOCTYPE|<!ENTITY/i.test(source)) throw Error("XML 외부 선언은 허용하지 않습니다.");
const doc = new DOMParser().parseFromString(source, "application/xml");
if (doc.querySelector("parsererror")) throw Error("Excel XML 구문 오류");
return doc;
};
const shared = files["xl/sharedStrings.xml"]
? [...parse(files["xl/sharedStrings.xml"]).getElementsByTagNameNS("*", "si")].map((si) =>
[...si.getElementsByTagNameNS("*", "t")].map((t) => t.textContent).join(""),
)
: [];
const rels = [
...parse(files["xl/_rels/workbook.xml.rels"]).getElementsByTagNameNS("*", "Relationship"),
];
const tables = {};
for (const sheet of parse(files["xl/workbook.xml"]).getElementsByTagNameNS("*", "sheet")) {
const id = sheet.getAttributeNS(
"http://schemas.openxmlformats.org/officeDocument/2006/relationships",
"id",
),
target = rels.find((r) => r.getAttribute("Id") === id)?.getAttribute("Target") || "",
path = target.startsWith("/") ? target.slice(1) : "xl/" + target;
if (!files[path]) continue;
const table = [];
for (const row of parse(files[path]).getElementsByTagNameNS("*", "row")) {
const line = Number(row.getAttribute("r")) || table.length + 1,
values = [];
for (const cell of row.getElementsByTagNameNS("*", "c")) {
const at = columnIndex(cell.getAttribute("r")?.match(/^[A-Z]+/)?.[0] || "");
if (at < 0) continue;
const type = cell.getAttribute("t"),
v = cell.getElementsByTagNameNS("*", "v")[0]?.textContent;
if (type === "s") values[at] = shared[Number(v)];
else if (type === "inlineStr")
values[at] = [...cell.getElementsByTagNameNS("*", "t")]
.map((t) => t.textContent)
.join("");
else if (type === "e") values[at] = null;
else if (type === "str" || type === "b") values[at] = v ?? "";
else values[at] = v == null ? null : Number(v);
}
table[line - 1] = values;
}
tables[sheet.getAttribute("name")] = table;
}
return tables;
}
function preview(state) {
const map = state.excelMap,
table = map.tables[map.sheet] || [],
shown = table.slice(map.start - 1, map.start + 7),
{ esc } = EstApp;
const width = Math.min(16, Math.max(1, ...shown.map((r) => (r || []).length)));
const head = Array.from({ length: width }, (_, i) => `<th>${columnName(i)}</th>`).join("");
const body = shown
.map(
(cells, i) =>
`<tr><th>${map.start + i}</th>${Array.from({ length: width }, (_, c) => `<td>${esc(cells?.[c] ?? "")}</td>`).join("")}</tr>`,
)
.join("");
return `<div style="max-height:200px;overflow:auto;border:1px solid #ccc"><table class="sheet" style="font-size:11px"><thead><tr><th></th>${head}</tr></thead><tbody>${body}</tbody></table></div>`;
}
function form(state) {
const map = state.excelMap,
{ esc } = EstApp;
const sheets = Object.keys(map.tables)
.map(
(name) =>
`<option value="${esc(name)}" ${name === map.sheet ? "selected" : ""}>${esc(name)}</option>`,
)
.join("");
const mode = (value, label) =>
`<option value="${value}" ${map.mode === value ? "selected" : ""}>${label}</option>`;
EstApp.showDialog(
"Excel 내역 열 맞춰 가져오기",
`<div class="form-fields"><label>시트<select name="xm-sheet">${sheets}</select></label><label>시작행<input name="xm-start" type="number" value="${map.start}"></label><label>끝행<input name="xm-end" type="number" value="${map.end}"></label><label>가져오기<select name="xm-mode">${mode("append", "순공사비 위에 추가")}${mode("replace", "순공사비 위 행을 이것으로 바꾸기")}</select></label></div><p>각 항목이 있는 Excel 열 문자(A, B …)를 적으세요. 비우면 가져오지 않습니다. 금액은 단가×수량으로 다시 계산합니다.</p><div class="form-fields">${fields.map(([key, label]) => `<label>${label}<input name="xm-${key}" value="${esc(map.columns[key] || "")}" size="3"></label>`).join("")}</div><button data-action="excel-map-preview">미리보기 새로고침</button> <button data-action="excel-map-apply">가져오기</button><div id="excel-map-preview">${preview(state)}</div>`,
);
}
function read(state) {
const value = (name) => document.querySelector(`#dialog-content [name="${name}"]`)?.value;
const map = state.excelMap;
map.sheet = value("xm-sheet") ?? map.sheet;
map.start = Number(value("xm-start")) || 1;
map.end = Number(value("xm-end")) || map.start;
map.mode = value("xm-mode") || "append";
for (const [key] of fields) map.columns[key] = letters(value(`xm-${key}`));
}
function load(state, files) {
const tables = sheetTables(files),
sheet = Object.keys(tables)[0];
if (!sheet) throw Error("읽을 시트가 없습니다.");
state.excelMap = {
start: 1,
mode: "append",
columns: {},
...state.excelMap,
tables,
sheet,
end: tables[sheet].length,
documentKey: state.documentKey,
};
form(state);
}
function open(state) {
if (state.view !== "EstMainDat") throw Error("내역서 화면에서 Excel 내역을 가져오세요.");
const input = document.createElement("input");
input.type = "file";
input.accept = ".xlsx";
input.addEventListener("change", async () => {
try {
if (!input.files[0]) return;
const body = await input.files[0].arrayBuffer();
load(state, await EstApp.api("/api/import-xlsx", { method: "POST", body }));
} catch (error) {
state.notice = error.message;
EstApp.render();
}
});
input.click();
}
function apply(state) {
read(state);
const map = state.excelMap;
if (map.documentKey !== state.documentKey) throw Error("작업이 바뀌었습니다. 다시 여세요.");
const list = records(map.tables[map.sheet] || [], map.start, map.end, map.columns),
{ rows: built, warnings } = rows(state.project, list);
if (!built.length) throw Error("가져올 행이 없습니다. 시작행·끝행과 품명 열을 확인하세요.");
const headings = built.filter((row) => Number(row.Level) > 0).length,
where = map.mode === "replace" ? "순공사비 위 행을 바꿔" : "순공사비 위에",
differ = warnings.length
? `\n합계단가와 노무·재료·경비 단가 합이 다른 행: ${warnings.slice(0, 5).join(", ")}`
: "";
if (!confirm(`${built.length}행(공종 제목 ${headings}행)을 ${where} 넣습니다.${differ}`))
return;
EstApp.byId("dialog").close();
EstApp.change((project) => insert(project, built, map.mode));
state.notice = `Excel에서 ${built.length}행을 가져왔습니다. 단가 고르기로 기초자료에 연결할 수 있습니다.`;
}
function handle(name, state) {
if (name === "excel-map-open") open(state);
else if (name === "excel-map-preview") {
read(state);
form(state);
} else if (name === "excel-map-apply") apply(state);
else return false;
return true;
}
return { fields, columnIndex, columnName, records, rows, insert, load, handle };
})();
+1
View File
@@ -61,6 +61,7 @@ const allowed = new Set([
"native-find.js",
"native-blank.js",
"native-quantity-sheets.js",
"native-excel-map.js",
"native-issues.js",
"native-attachments.js",
"native-standard.js",