From b14c4fccaffd301916d9250398ea61efb78bd374 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 6 Nov 2026 23:04:58 +0900 Subject: [PATCH] feat(est): show existing quantity links --- resources/tester/test_est_native_export.py | 15 +++++++ tools/est-test/README.md | 2 +- tools/est-test/import-estx.cjs | 2 + tools/est-test/native-links.cjs | 50 ++++++++++++++++++++++ 4 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 tools/est-test/native-links.cjs diff --git a/resources/tester/test_est_native_export.py b/resources/tester/test_est_native_export.py index c5b002ec8..bb132bdba 100644 --- a/resources/tester/test_est_native_export.py +++ b/resources/tester/test_est_native_export.py @@ -8,6 +8,21 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[2] +def test_existing_quantity_links_respect_native_mode_and_avoid_ambiguous_targets(): + script = """const {existingTargets}=require('./tools/est-test/native-links.cjs'); + const sheet={Code:'N1',items:[{Code:'r1'},{Code:'r2'},{Code:'r3'},{Code:'r4'}]}; + const blob=(v,enc='utf8')=>({binary:Buffer.from(v,enc).toString('base64')}); + const rows=[{Code:'E1',MullangIndex:1,MulCode:blob('N1\\tr1')}, + {Code:'E2',MullangIndex:1,MulCode:blob('N1\\tr2','utf16le')}, + {Code:'E3',MullangIndex:2,MulCode:blob('N1\\tr3')}, + {Code:'E4',MullangIndex:1,MulCode:blob('N1\\tr4')},{Code:'E4'}]; + const warnings=new Set();existingTargets({EstMainDat:rows},[sheet],warnings); + process.stdout.write(JSON.stringify({items:sheet.items,warnings:[...warnings]}));""" + result = json.loads(subprocess.check_output(["node", "-e", script], cwd=ROOT).decode("utf-8")) + assert [item.get("TargetCode") for item in result["items"]] == ["E1", "E2", None, None] + assert len(result["warnings"]) == 2 + + def test_estx_print_catalog_uses_project_options_when_sample_exists(): sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/sample.estx") if not sample.exists(): diff --git a/tools/est-test/README.md b/tools/est-test/README.md index 2c82a1ca9..c3482e408 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -23,7 +23,7 @@ Ctrl+S는 입력 중인 셀도 확정한 뒤 저장합니다. 연속 저장 요 당초 확정 사본에는 재계산 입력을 한 번만 보관하며, 작업본의 도면·원본 압축 자료는 보존합니다. 설치된 `main.estx`의 당초 확정 후 실제 저장 API를 통과했고, 산근 77,395행과 원본 압축 해시 보존·계산 오류 0을 확인했습니다(요청 약 215MB, 저장 약 221MB). -초기 자료는 기술정보 DB의 실제 REA 저장 샘플입니다. `REA 샘플 대조`에서 원본 저장값과 275개 계산 결과를 비교합니다. 테스트용 임의 요율을 원본 기본값으로 넣지 않습니다. 새 원가 서식의 요율은 사용자가 입력합니다. +초기 자료는 기술정보 DB의 실제 REA 저장 샘플입니다. `REA 샘플 대조`에서 원본 저장값과 275개 계산 결과를 비교합니다. 기존 ESTX의 기본 수량 연결은 문서·항목 코드와 내역 코드가 유일한 경우 화면에 표시하며, 중복·다른 모드는 원문에 보존하고 경고합니다. 원본 연결 데이터 실자료 검증과 새 연결의 ESTX 쓰기는 아직 지원하지 않습니다. 테스트용 임의 요율을 원본 기본값으로 넣지 않습니다. 새 원가 서식의 요율은 사용자가 입력합니다. ## 검증과 남은 범위 diff --git a/tools/est-test/import-estx.cjs b/tools/est-test/import-estx.cjs index 223f26b13..fce5bf1c6 100644 --- a/tools/est-test/import-estx.cjs +++ b/tools/est-test/import-estx.cjs @@ -2,6 +2,7 @@ const zlib = require("node:zlib"); const quantity = require("./native-quantity.cjs"); const { readPrintOptions } = require("./import-print.cjs"); +const { existingTargets } = require("./native-links.cjs"); const decoder = new TextDecoder("euc-kr", { fatal: true }); const LIMIT = 256 * 1024 * 1024; const crcTable = Array.from({ length: 256 }, (_, n) => { @@ -367,6 +368,7 @@ function importEstx(buffer, name = "가져온 내역서") { warnings.add(`수량산출 ${row.Code}: ${error.message}`); } } + existingTargets(tables, quantitySheets, warnings); return { schema: "est-web-2", name: props.TCommonOption?.ProjectName || name, diff --git a/tools/est-test/native-links.cjs b/tools/est-test/native-links.cjs new file mode 100644 index 000000000..52e011d30 --- /dev/null +++ b/tools/est-test/native-links.cjs @@ -0,0 +1,50 @@ +// REA 0x11167e4 / 0xa911d0: MulCode is split at a tab into sheet and item codes. +function existingTargets(tables, sheets, warnings) { + const candidates = new Map(); + const targetCounts = new Map(); + for (const row of tables.EstMainDat || []) + if (row.Code && !Number(row.Level)) + targetCounts.set(row.Code, (targetCounts.get(row.Code) || 0) + 1); + for (const row of tables.EstMainDat || []) { + if (Number(row.Level) || !row.MulCode?.binary || !row.MullangIndex || row.ExcelSheetIndex) + continue; + if (row.MullangIndex !== 1) { + warnings.add("수량 연결: 기본 모드 외 연결은 원문에 보존했습니다."); + continue; + } + const bytes = Buffer.from(row.MulCode.binary, "base64"); + let text; + try { + let wide = bytes.length > 1 && bytes.length % 2 === 0; + for (let i = 1; wide && i < bytes.length; i += 2) wide = bytes[i] === 0; + text = wide + ? bytes.toString("utf16le") + : new TextDecoder("euc-kr", { fatal: true }).decode(bytes); + } catch { + warnings.add("수량 연결: 코드 인코딩을 읽을 수 없습니다."); + continue; + } + const parts = text.replace(/\0+$/, "").split("\t"); + if (parts.length !== 2 || !parts[0] || !parts[1] || !row.Code) { + warnings.add("수량 연결: 문서·항목 코드 또는 내역 코드가 없습니다."); + continue; + } + if (targetCounts.get(row.Code) !== 1) { + warnings.add(`수량 연결: 내역 코드 ${row.Code}가 중복되어 자동 선택하지 않았습니다.`); + continue; + } + const sheet = sheets.find((value) => value.Code === parts[0]); + const item = sheet?.items.find((value) => value.Code === parts[1]); + if (!item) { + warnings.add(`수량 연결: ${parts[0]}/${parts[1]} 항목을 찾을 수 없습니다.`); + continue; + } + if (!candidates.has(item)) candidates.set(item, new Set()); + candidates.get(item).add(row.Code); + } + for (const [item, codes] of candidates) + if (codes.size === 1) item.TargetCode = [...codes][0]; + else + warnings.add(`수량 연결: ${item.Code}에 여러 내역 코드가 연결되어 자동 선택하지 않았습니다.`); +} +module.exports = { existingTargets };