feat(est-test): Excel 내역 열 맞춰 가져오기
- 원본 엑셀 변환 내역서 탭처럼 시트·행 범위·항목별 열 문자로 남의 Excel 내역을 읽음 - 미리보기, 순공사비 위 추가·바꾸기, 단위·수량 없는 줄은 공종 제목 - 수식 셀은 저장된 계산값 사용, 합계단가만 있는 줄은 비목 배분 없이 거부 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
@@ -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. 무관자료 삭제, 데이타 정리(정렬·번호), 품명정리.
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
|
||||
내역서·일위대가 화면의 `편집 → 단가 고르기`는 자재·노임·기계경비·일위대가·단가산출·견적·실적 자료를 검색어로 찾아 선택 행 아래에 넣거나, 선택 행 하나의 연결을 바꿉니다. 여러 개를 골라 한 번에 넣을 수 있고, 바꿀 때는 변경 전·후를 확인한 뒤 같은 코드를 쓰는 이 표의 다른 행도 함께 바꿀지 고릅니다. 넣거나 바꾼 행만 원천 단가를 다시 읽습니다. 단가산출 산출근거 행은 대상이 아닙니다.
|
||||
|
||||
`파일 → Excel 내역 열 맞춰 가져오기`는 원본 엑셀→EST 변환처럼 다른 프로그램이나 발주처의 Excel 내역을 읽습니다. 시트·시작행·끝행과 번호·품명·규격·단위·수량·단가 등 항목별 Excel 열 문자를 적고 미리보기로 확인한 뒤, 순공사비 줄 위에 추가하거나 그 위 행을 바꿉니다. 단위와 수량이 없는 줄은 공종 제목이 되고, 금액은 단가×수량으로 다시 계산합니다. 수식 셀은 Excel이 저장해 둔 값을 씁니다. 합계단가만 있고 노무·재료·경비로 나눈 단가가 없으면 비목을 정할 수 없어 가져오지 않습니다.
|
||||
|
||||
`파일 → 공내역 파일로 저장`은 원본처럼 내역·일위대가의 금액을 비운 사본을 내려받습니다. 남길 값은 수량만(단가 비움)·단가만(수량 비움)·수량·단가(금액만 비움) 중에서 고르고, 원본 ESTX를 가져온 작업은 ESTX로도 받을 수 있습니다. 사본은 자동계산을 끈 상태로 저장해 열어도 빈칸이 유지되며, 원가계산서 금액도 비웁니다. 현재 작업은 바뀌지 않습니다.
|
||||
|
||||
`편집 → 찾기·다음찾기`(Ctrl+F)는 원본 찾기 창처럼 검색어를 처음부터 또는 현재 위치부터 찾아 해당 행으로 이동합니다. 일위대가·단가산출·기계경비는 찾은 행의 호표로 넘어갑니다. 단가산출에서는 찾은 행의 문구를 한 건씩 바꾸고 다음 위치로 이동하며, 모두 바꾸기는 `산출근거 문구 바꾸기`를 씁니다. 표 위 검색창은 걸러 보기로 그대로 남습니다.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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 };
|
||||
})();
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user