feat(est-test): inspect native progress tables
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
import json
|
||||
import struct
|
||||
import subprocess
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
|
||||
from test_est_native_export import ROOT, fixture
|
||||
|
||||
|
||||
def inspect(path):
|
||||
script = """const fs=require('fs'),m=require('./tools/est-test/inspect-accom.cjs');
|
||||
try { process.stdout.write(JSON.stringify(m.inspectAccom(fs.readFileSync(process.argv[1])))); }
|
||||
catch(e) { process.stdout.write(JSON.stringify({error:e.message})); }"""
|
||||
return json.loads(subprocess.check_output(["node", "-e", script, str(path)], cwd=ROOT))
|
||||
|
||||
|
||||
def test_accom_reads_synthetic_table_and_rejects_missing_or_bad_signature(tmp_path):
|
||||
base = tmp_path / "base.estx"
|
||||
fixture(base)
|
||||
with zipfile.ZipFile(base) as archive:
|
||||
data = bytearray(archive.read("EstMainDat.dat"))
|
||||
# Fixture GU is a double field; rename it to the observed Accom quantity field.
|
||||
offset = 512 + 768 * 4
|
||||
data[offset + 2] = len("TotalGN")
|
||||
data[offset + 3 : offset + 3 + len("TotalGN")] = b"TotalGN"
|
||||
path = tmp_path / "accom.estx"
|
||||
with zipfile.ZipFile(path, "w") as archive:
|
||||
archive.writestr("AccomMainTable.Dat", data)
|
||||
result = inspect(path)
|
||||
assert result["rows"][0]["Code"] == "A"
|
||||
assert result["rows"][0]["TotalGN"] == 100
|
||||
assert "TotalGN" in result["fields"]
|
||||
script = """const fs=require('fs'),{server}=require('./tools/est-test/server.cjs');
|
||||
server.listen(0,'127.0.0.1',async()=>{try{
|
||||
const r=await fetch(`http://127.0.0.1:${server.address().port}/api/inspect-accom`,
|
||||
{method:'POST',body:fs.readFileSync(process.argv[1])});
|
||||
const p=await r.json();process.stdout.write(JSON.stringify({status:r.status,rows:p.rows}));
|
||||
}finally{server.close()}});"""
|
||||
api = json.loads(subprocess.check_output(["node", "-e", script, str(path)], cwd=ROOT))
|
||||
assert api["status"] == 200
|
||||
assert api["rows"][0]["TotalGN"] == 100
|
||||
assert "없습니다" in inspect(base)["error"]
|
||||
data[24] ^= 1
|
||||
with zipfile.ZipFile(path, "w") as archive:
|
||||
archive.writestr("AccomMainTable.Dat", data)
|
||||
assert "서명" in inspect(path)["error"]
|
||||
|
||||
|
||||
def test_accom_view_reads_file_without_changing_project(tmp_path):
|
||||
playwright = pytest.importorskip("playwright.sync_api")
|
||||
base = tmp_path / "base.estx"
|
||||
fixture(base)
|
||||
with zipfile.ZipFile(base) as archive:
|
||||
data = bytearray(archive.read("EstMainDat.dat"))
|
||||
offset = 512 + 768 * 4
|
||||
data[offset + 2] = len("TotalGN")
|
||||
data[offset + 3 : offset + 3 + len("TotalGN")] = b"TotalGN"
|
||||
stride = struct.unpack_from("<H", data, 45)[0]
|
||||
data.extend(data[-stride:] * 200)
|
||||
struct.pack_into("<I", data, 41, 201)
|
||||
path = tmp_path / "accom.estx"
|
||||
with zipfile.ZipFile(path, "w") as archive:
|
||||
archive.writestr("AccomMainTable.Dat", data)
|
||||
with playwright.sync_playwright() as p:
|
||||
browser = p.chromium.launch(channel="msedge", headless=True)
|
||||
page = browser.new_page()
|
||||
try:
|
||||
page.goto("http://100.65.141.15:5175", timeout=5000)
|
||||
except Exception:
|
||||
browser.close()
|
||||
pytest.skip("EST test server unavailable")
|
||||
page.wait_for_function("() => Boolean(EstApp.state.project)")
|
||||
page.evaluate("EstApp.state.view='progress'; EstApp.render()")
|
||||
before = page.evaluate("JSON.stringify(EstApp.state.project)")
|
||||
page.locator("[data-accom-inspect]").set_input_files(str(path))
|
||||
page.locator("#dialog-content").get_by_text("TotalGN").wait_for(timeout=5000)
|
||||
assert page.locator("#dialog-content").get_by_text("100", exact=True).count() == 200
|
||||
page.locator('[data-accom-page="1"]').click()
|
||||
assert page.locator("#dialog-content").get_by_text("100", exact=True).count() == 1
|
||||
assert page.evaluate("JSON.stringify(EstApp.state.project)") == before
|
||||
page.evaluate("""() => {
|
||||
document.getElementById('dialog').close();
|
||||
const original=window.fetch;
|
||||
window.fetch=(url,options)=>url==='/api/inspect-accom'
|
||||
? new Promise(resolve=>window.releaseAccom=()=>resolve(original(url,options)))
|
||||
: original(url,options);
|
||||
}""")
|
||||
page.locator("[data-accom-inspect]").set_input_files(str(path))
|
||||
page.wait_for_function("() => typeof window.releaseAccom === 'function'")
|
||||
page.evaluate("EstApp.showDialog('편집 중', '<input id=keep-value value=보존>')")
|
||||
with page.expect_event("dialog") as dialog_event:
|
||||
page.evaluate("window.releaseAccom()")
|
||||
dialog_event.value.accept()
|
||||
assert page.locator("#dialog-title").inner_text() == "편집 중"
|
||||
assert page.locator("#keep-value").input_value() == "보존"
|
||||
browser.close()
|
||||
@@ -0,0 +1,86 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from test_est_native_export import ROOT
|
||||
|
||||
|
||||
HELPER = r"""const fs=require('fs'),path=require('path'),vm=require('vm');
|
||||
const base=path.resolve('tools/est-test'),m=require(path.join(base,'import-estx.cjs')),
|
||||
e=require(path.join(base,'export-estx.cjs')),context=vm.createContext({structuredClone,console,Blob});
|
||||
for(const name of ['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'])
|
||||
vm.runInContext(fs.readFileSync(path.join(base,name),'utf8'),context,{filename:name});
|
||||
const bytes=fs.readFileSync(process.argv[1]),p=m.importEstx(bytes);
|
||||
if(process.argv[2]==='exclude-fee')p.options.GangubExceptJodalRatio=true;
|
||||
p.annual=[{name:'1차',formula:'[기준]*0.5'}];context.p=p;
|
||||
const q=vm.runInContext('EstBusiness.annualCopy(p,0)',context);
|
||||
"""
|
||||
|
||||
|
||||
def run_sample(path, script, *args):
|
||||
if not path.exists():
|
||||
pytest.skip("EST 원본 시험 파일 없음")
|
||||
return json.loads(
|
||||
subprocess.check_output(["node", "-e", HELPER + script, str(path), *args], cwd=ROOT)
|
||||
)
|
||||
|
||||
|
||||
def test_annual_copy_exports_and_reopens_native_without_index_changes():
|
||||
path = next(
|
||||
iter(
|
||||
(ROOT / "resources/knowledge/original/경쟁사 프로그램/EST Plus 2/example").glob(
|
||||
"01*.estx"
|
||||
)
|
||||
),
|
||||
Path("missing.estx"),
|
||||
)
|
||||
result = run_sample(
|
||||
path,
|
||||
r"""const out=e.exportEstx(q),back=m.importEstx(out);context.back=back;
|
||||
const recalculated=vm.runInContext('EstNative.calculate(back)',context);
|
||||
const old=m.unzip(bytes),updated=m.unzip(out);
|
||||
const fields=['GN','NC','GC','JC','SC','TC'];
|
||||
const equal=q.tables.EstMainDat.length===recalculated.tables.EstMainDat.length&&
|
||||
q.tables.EstMainDat.every((r,i)=>fields.every(k=>(r[k]||0)===(recalculated.tables.EstMainDat[i][k]||0)));
|
||||
const indexes=[...old].filter(([k])=>k.endsWith('.idx')).every(([k,v])=>v.equals(updated.get(k)));
|
||||
const costs=q.tables.Wonga.map(r=>r.Cost),costsBack=recalculated.tables.Wonga.map(r=>r.Cost);
|
||||
process.stdout.write(JSON.stringify({equal,indexes,costs,costsBack,errors:recalculated.calculation.errors}));""",
|
||||
)
|
||||
assert result["equal"] and result["indexes"]
|
||||
assert result["costs"] == result["costsBack"]
|
||||
assert not result["errors"]
|
||||
|
||||
|
||||
def test_annual_procurement_previous_link_survives_native_export():
|
||||
path = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/sample.estx")
|
||||
result = run_sample(
|
||||
path,
|
||||
r"""const out=e.exportEstx(q),back=m.importEstx(out);context.back=back;
|
||||
const after=vm.runInContext('EstNative.calculate(back)',context);
|
||||
const old=m.unzip(bytes),updated=m.unzip(out);
|
||||
process.stdout.write(JSON.stringify({before:q.tables.Wonga.map(r=>r.Cost),
|
||||
after:after.tables.Wonga.map(r=>r.Cost),errors:after.calculation.errors,
|
||||
fee:after.tables.EstMainDat.filter(r=>r.LinkIndex===99&&r.LinkIndex_===57).length,
|
||||
indexes:[...old].filter(([k])=>k.endsWith('.idx')).every(([k,v])=>v.equals(updated.get(k)))}));""",
|
||||
"exclude-fee",
|
||||
)
|
||||
assert result["fee"] > 0
|
||||
assert result["before"] == result["after"]
|
||||
assert result["indexes"] and not result["errors"]
|
||||
|
||||
|
||||
def test_annual_procurement_without_previous_link_cannot_be_silently_lost():
|
||||
path = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/sample.estx")
|
||||
result = run_sample(
|
||||
path,
|
||||
r"""q.tables.EstMainDat.find(r=>r.AnnualSourceLink===57).LinkIndex_=null;
|
||||
let message='';try{e.exportEstx(q)}catch(error){message=error.message}
|
||||
process.stdout.write(JSON.stringify({fee:q.tables.EstMainDat.filter(r=>r.AnnualSourceLink===57).length,message}));""",
|
||||
"exclude-fee",
|
||||
)
|
||||
assert result["fee"] > 0
|
||||
assert "관급 수수료" in result["message"]
|
||||
@@ -0,0 +1,18 @@
|
||||
import json
|
||||
import subprocess
|
||||
|
||||
from test_est_native_export import ROOT, fixture
|
||||
|
||||
|
||||
def test_missing_index_preserves_original_but_blocks_modified_export(tmp_path):
|
||||
source = tmp_path / "source.estx"
|
||||
fixture(source)
|
||||
script = """const fs=require('fs'),m=require('./tools/est-test/import-estx.cjs'),
|
||||
e=require('./tools/est-test/export-estx.cjs'),a=require('./tools/est-test/archive.cjs');
|
||||
const files=m.unzip(fs.readFileSync(process.argv[1]));files.delete('estmaindat.idx');
|
||||
const original=a.pack(files),p=m.importEstx(original),unchanged=e.exportEstx(p).equals(original);
|
||||
p.tables.EstMainDat[0].GN=3;let error='';try{e.exportEstx(p)}catch(err){error=err.message}
|
||||
process.stdout.write(JSON.stringify({unchanged,error}));"""
|
||||
result = json.loads(subprocess.check_output(["node", "-e", script, str(source)], cwd=ROOT))
|
||||
assert result["unchanged"]
|
||||
assert "인덱스 파일이 없어" in result["error"]
|
||||
@@ -423,6 +423,19 @@ 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)
|
||||
|
||||
- [x] 연차 수수료의 원본 연결값 `LinkIndex_`가 57로 보존된 경우 ESTX 재열기에서 원가 계산에 반영하고, 없는 경우 저장 차단을 유지한다.
|
||||
- 자체 검증: 설치 sample의 조달수수료 2건을 연차 사본으로 저장하고 관급 수수료 제외 옵션을 켠 상태에서 재열어 원가 전 항목과 인덱스 바이트가 일치함을 확인. 기존 연결값이 없는 경우 저장 거부. 연차·기성·인덱스 관련 통합 회귀 43개 통과. `99`·고정 행·기존 연결값 `57` 조합에만 적용하며 원본 EST 실행 비교는 하지 못했다.
|
||||
|
||||
- [x] 원본 ESTX에서 인덱스가 빠진 DBISAM 표가 있으면 수정본 출력 시 이를 차단한다. 변경 없는 원본 반환은 유지하고 설치 자료·합성 결손 사례를 확인한다.
|
||||
- 자체 검증: 설치 ESTX 24개에서 유효한 DBISAM `.dat`의 짝 `.idx` 누락이 없음을 확인. 합성 파일의 인덱스를 제거하면 원본 그대로 반환은 가능하고 값 수정 저장은 오류로 차단. 관련 인덱스·연차·기성 읽기 시험 5개 통과. 인덱스 재생성 구현은 별도로 미완료.
|
||||
|
||||
- [x] 연차 사본의 안전한 ESTX 저장 범위를 실제 파일로 확인하고, 원본 복원에 필요한 관급 수수료 정보가 소실되는 경우 저장을 차단한다.
|
||||
- 자체 검증: 사용자 지정 통영 ESTX의 50% 차수 사본을 ESTX로 저장·재열어 내역 144행의 수량·비목·금액, 원가와 인덱스 바이트가 일치함을 확인. 관급 수수료 행을 99로 바꾸는 차수는 원래 57 정보가 원본 ESTX에서 사라지면 안전관리비 원가가 달라질 수 있음을 설치 sample에서 재현. 이전 연결값이 보존되지 않는 경우 저장을 명시적으로 거부한다. 원본 프로그램 실행 검증은 하지 못했다.
|
||||
- 독립 읽기 도구 PyDBISAM 1.2.1도 시험했지만 저장본과 변경 전 원본 모두 같은 필드형 오류로 열지 못해 독립 판정 근거로 사용하지 않았다.
|
||||
|
||||
- [x] 원본 기성 ESTX에서 `AccomMainTable.Dat`를 읽어 표를 확인하는 기능을 추가한다. 원본·작업본 변경 없이 합성 테이블과 오류 조건, 서버 및 화면 연결을 검증한다. 원본 회차 변환과 쓰기는 실제 자료 확인 전까지 차단한다.
|
||||
- 자체 검증: DBISAM 서명·코드/누계 필드 검사, 원본 표 읽기, 표 누락·서명 손상 거부, 별도 서버 API 및 Edge에서 파일 선택·201행 페이지 이동과 현재 작업 불변 확인. 파일을 읽는 도중 다른 입력창을 열어도 덮어쓰지 않는다. 합성 자료·기성·연차·서명 시험 18개 통과. 실제 원본 기성 파일은 확보하지 못해 호환성과 원본 실행 결과는 미확인이다.
|
||||
|
||||
- [x] 임도 원본 장비운반 산식의 괄호 누락을 유사 원본 자료와 대조해 별도 보정본에 반영하고, 연결 단가·계산·저장 재열기를 확인한다. 원본 ESTX는 유지한다.
|
||||
- 근거: 설치된 산림토목·오수차집관거 등의 같은 왕복 운반식은 분수 합계의 닫는 괄호 뒤에 `×2×60`을 둔다. 원본의 `0.56`은 18.3/40+2/20의 시간 단위 반올림과 일치하고, 왕복 분 단위 산식 결과는 `(18.3/40+2/20+0/10)×2×60=66.9`이다. 이는 원본을 실행해 확인한 값이 아니라 유사 자료·단위에 근거한 보정이다.
|
||||
- 자체 검증: 서버에 `2025년 산불진화임도 설치사업 실시설계 (장비운반 산식 보정 검토본)`을 별도 저장(ID `aab5f5f5e7633447a07e14e9`)하고 재열기·2회 반복 계산에서 오류 0과 결과 불변을 확인했다. 산근 내 수치 대입 설명도 변경 결과와 일치시켰다. 원본 단가 113,252→366,863원, 연결 내역 226,504→733,726원, 순공사비 267,387,307→267,894,529원. 원본의 도급액 고정 옵션 때문에 도급액은 508,328,000원으로 유지된다. 원본 ESTX 해시 변경 없음.
|
||||
@@ -867,6 +880,7 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD
|
||||
- [ ] ESTX 신규 파일/행 증감·키 변경의 인덱스 재생성 구현 가능성 조사 및 점검.
|
||||
- 조사 및 한계 분석(2026-11-08):
|
||||
1. 파일 구조: 설치 ESTX 24개에 든 `.idx` 468개의 오프셋 9~24는 모두 `8abe8e592364cb403d71d2e3bc64d001`이다. 이 값은 DBISAM 공식 문서의 기본 엔진 서명 `DBISAM_SIG`를 MD5 해시한 값과 일치한다. 바로 앞 `06`은 해시의 일부가 아니다. 교량 샘플에서는 비어 있는 페이지와 레코드 번호처럼 보이는 평문 바이트열도 확인되어 전체가 암호화됐다는 기존 추정은 잘못됐다. 페이지·트리 갱신 규칙은 미확인이므로 쓰기에는 사용하지 않는다: https://www.elevatesoft.com/manual?action=viewtopic&id=dbisam4&product=rscpp&topic=customizing_engine&version=XE .
|
||||
- 추가 조사(2026-11-11): 설치 `sample.estx`의 내역 표는 76행일 때 인덱스 첫 4바이트 값 77이지만, 빈 `CadMainDat`은 3이고 빈 `CadTitleDat`은 1이다. 이 값만 행 수에 맞추는 방식으로는 인덱스를 재생성할 근거가 없다. 공식 문서는 한 인덱스 파일이 여러 인덱스 정의와 페이지를 포함한다고 설명한다: https://www.elevatesoft.com/manual?action=viewtopic&id=dbisam4&product=rsdelphiwin32&topic=dbisam_architecture&version=11 .
|
||||
- 추가 조사: DBISAM은 `REPAIR TABLE ... FORCEINDEXREBUILD`를 제공한다(https://www.elevatesoft.com/manual?action=viewtopic&id=dbisam4&product=rsdelphiwin64&topic=repair_table_statement&version=XE5). 이 PC의 32·64비트 ODBC 등록 목록에서 DBISAM 드라이버를 찾지 못했다. 공식 시험판 다운로드는 로그인 후 가능하며, 원본 엔진 서명·버전과의 호환성은 확인하지 못했다.
|
||||
2. 무결성 검증 제약: `exportEstx`는 동일 행 수 및 비인덱스 필드(수치, 일반 텍스트, BLOB 데이터 등)의 수정 저장을 안전하게 지원하지만, 행 추가/삭제나 인덱스 키(`PkID`, `Code`, `Index`, `RecNo` 등)를 변경하면 `.idx`의 B-Tree 구조와 불일치가 발생함. 원본 Delphi/DBISAM 엔진 외부에서 인덱스 재생성을 구현할 수 있는 바이너리 규격이나 소스 근거가 부재함.
|
||||
3. 구현 범위 및 처리: 따라서 ESTX 내 행 추가/삭제 및 인덱스 키 변경은 기존처럼 명확한 예외 메시지(`{key}: 행 추가/삭제는 인덱스 재생성이 필요합니다`, `{key}.{name}: 인덱스 키 변경은 지원하지 않습니다`)로 안전 차단하고, 신규 파일/행 증감/자유로운 편집이 필요한 작업은 JSON 저장소(`project.tables`)에서 완전 무결하게 지원·보존하도록 범위를 명확히 유지함.
|
||||
|
||||
@@ -55,6 +55,8 @@
|
||||
- 특수 기계 상세와 퍼센트 행도 수량 적용 금액이 호표 합계와 명확히 맞는 경우 개별 고정 금액으로 표시합니다. 해석이 모호하거나 합계가 불일치하면 호표 합계를 보존합니다.
|
||||
- 단가산출 호표의 저장된 비목별 금액은 JDL 단가산출 자료로 내보내 내역·일위 연결을 유지합니다. 원래 산출근거의 식은 JDL에서 복원되지 않습니다.
|
||||
- **원본 구조 유지 ESTX 수정 저장**: 가져온 파일의 공사명·계산 옵션과 행 수 및 인덱스 키가 유지된 단가·수량·문구·금액, 기존 BLOB과 원가 서식을 기록하고 재읽기로 검산합니다. 기존 수량산출서의 이름과 항목을 수정하고, 확인된 형식의 항목과 단일 결과 표지 산출 행을 추가·삭제할 수 있습니다. 고유한 일반 행 삭제와 조건에 맞는 산출 행 이동도 지원합니다. 연결된 항목이나 중복·복합 표지, 불명확한 서식 구조는 보호를 위해 일부 작업을 거부합니다. 기존 서식 구간의 텍스트를 편집할 때 새 글에는 변경 시작 위치의 서식이 적용됩니다. 원본 결과가 기존 계산과 모두 일치하면 수정된 계산 결과 표지와 수량 목록을 함께 갱신합니다. 불일치나 계산 오류가 있으면 저장을 거부합니다. 기존 BLOB은 공간이 부족하면 블록을 추가합니다. 원본 파일을 덮어쓰지 않으며 `.idx`와 CAD 바이너리를 보존합니다. 새 수량산출서, 인덱스 키 변경, 수량의 내역 연결 변경, 웹 기성·연차·당초 사본은 ESTX 저장을 지원하지 않아 JSON으로 보관합니다. 원본 EST 실행 검증은 못했습니다.
|
||||
- 연차 화면에서 차수 내역을 독립 사본으로 열면, 원본 구조 유지 조건을 충족하는 경우 해당 사본을 ESTX로 저장할 수 있습니다. 관급 수수료를 포함한 일부 차수는 원가 계산에 필요한 연결 정보를 원본 형식에 보존할 수 없어 JSON으로 저장해야 합니다.
|
||||
- 기성 화면의 **원본 기성 표 확인**은 선택한 ESTX의 `AccomMainTable.Dat` 내용을 읽기 전용으로 표시합니다. 실제 기성 파일로 검증하지 못했으며, 확인한 표를 웹 기성 회차에 적용하거나 원본에 쓰지는 않습니다.
|
||||
- **예정공정표**: 내역 행을 공정으로 일괄 추가하고 내역 금액으로 가중치를 갱신할 수 있습니다. 코드가 없거나 같은 코드가 반복된 행은 내역의 행 식별자로 구분합니다. 기존 공정의 기간·진도·이름은 유지합니다. 공정별 기간 막대와 입력 진도의 가중평균을 표시하며, 날짜 역전·잘못된 진도를 차단합니다. 공휴일/공기 산정 기준을 임의 적용하지 않습니다.
|
||||
- **기성 진도 반영**: 예정공정표에서 최근 확정 회차의 누계 수량을 해당 회차 계약 수량으로 나누어 연결된 공정의 진도를 갱신할 수 있습니다. 수량이 0이거나 기성 내역에 연결되지 않은 공정은 기존 진도를 유지합니다. 0~100%를 벗어나는 결과는 갱신을 중단합니다.
|
||||
- **기성 초기화**: 기성 화면에서 모든 회차와 확정 결과를 한 번에 초기화할 수 있습니다. 내역과 다른 업무 자료는 유지하고 실행 취소로 복원할 수 있습니다.
|
||||
|
||||
@@ -151,6 +151,17 @@ function updateBlob(file, start, id, bytes) {
|
||||
function exportEstx(project) {
|
||||
if (!project.originalArchive)
|
||||
throw Error("원본 ESTX를 가져온 작업에서만 ESTX 수정 저장이 가능합니다.");
|
||||
if (
|
||||
(project.tables?.EstMainDat || []).some(
|
||||
(row) =>
|
||||
Number(row.LinkIndex) === 99 &&
|
||||
Number(row.AnnualSourceLink) === 57 &&
|
||||
(Number(row.CalcIndex) !== 1 || Number(row.LinkIndex_) !== 57),
|
||||
)
|
||||
)
|
||||
throw Error(
|
||||
"연차 관급 수수료의 원본 연결은 ESTX에 보존할 수 없습니다. JSON 저장을 사용하세요.",
|
||||
);
|
||||
if (project.attachments?.length)
|
||||
throw Error("웹 첨부파일은 ESTX에 저장할 수 없습니다. JSON 저장을 사용하세요.");
|
||||
if (project.excelQuantityLinks?.length)
|
||||
@@ -321,6 +332,12 @@ function exportEstx(project) {
|
||||
changed++;
|
||||
}
|
||||
if (!changed) return original;
|
||||
for (const [name, data] of files)
|
||||
if (name.endsWith(".dat") && data.subarray(8, 25).equals(signature)) {
|
||||
const indexName = name.replace(/\.dat$/, ".idx");
|
||||
if (!files.has(indexName))
|
||||
throw Error(`${name}: 인덱스 파일이 없어 수정 저장할 수 없습니다.`);
|
||||
}
|
||||
const result = pack(files),
|
||||
check = importEstx(result);
|
||||
if (check.name !== project.name) throw Error("공사명 저장 후 재읽기 검산 실패");
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<script defer src="native-row-match.js"></script>
|
||||
<script defer src="native-escalation.js"></script>
|
||||
<script defer src="native-progress.js"></script>
|
||||
<script defer src="native-accom-view.js"></script>
|
||||
<script defer src="native-business.js"></script>
|
||||
<script defer src="native-review.js"></script>
|
||||
<script defer src="native-resources.js"></script>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
const { unzip, table } = require("./import-estx.cjs");
|
||||
|
||||
function inspectAccom(bytes) {
|
||||
const files = unzip(bytes),
|
||||
data = files.get("accommaintable.dat");
|
||||
if (!data) throw Error("기성 원본 표 AccomMainTable.Dat가 없습니다.");
|
||||
const signature = Buffer.from("068abe8e592364cb403d71d2e3bc64d001", "hex");
|
||||
if (data.length < 512 || !data.subarray(8, 25).equals(signature))
|
||||
throw Error("기성 원본 표의 DBISAM 서명이 올바르지 않습니다.");
|
||||
const warnings = new Set(),
|
||||
rows = table(data, files.get("accommaintable.blb"), warnings),
|
||||
fields = Array.from({ length: data.readUInt16LE(47) }, (_, index) => {
|
||||
const at = 512 + index * 768;
|
||||
return data.subarray(at + 3, at + 3 + data[at + 2]).toString("ascii");
|
||||
});
|
||||
if (!["Code", "TotalGN"].every((name) => fields.includes(name)))
|
||||
throw Error("기성 원본 표의 코드 또는 누계 수량 필드가 없습니다.");
|
||||
if (new Set(fields).size !== fields.length) throw Error("기성 원본 표에 중복 필드가 있습니다.");
|
||||
const hasBlob = fields.some((_, index) => data[512 + index * 768 + 164] === 3);
|
||||
if (hasBlob && !files.has("accommaintable.blb"))
|
||||
warnings.add("기성 표의 BLOB 파일이 없어 별도 바이너리 값은 확인할 수 없습니다.");
|
||||
return {
|
||||
fields,
|
||||
rows: rows.map((row) =>
|
||||
Object.fromEntries(
|
||||
fields.map((field) => [
|
||||
field,
|
||||
row[field] && typeof row[field] === "object" ? "[바이너리]" : row[field],
|
||||
]),
|
||||
),
|
||||
),
|
||||
warnings: [...warnings],
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { inspectAccom };
|
||||
@@ -0,0 +1,63 @@
|
||||
// 원본 기성 파일은 표만 확인하며 현재 작업에 반영하지 않는다.
|
||||
const EstAccomView = (() => {
|
||||
let current = null,
|
||||
page = 0;
|
||||
const pageSize = 200;
|
||||
const title = "원본 기성 표 확인";
|
||||
function render() {
|
||||
const { esc, showDialog } = EstApp;
|
||||
const { fields, rows, warnings, name } = current;
|
||||
const pages = Math.max(1, Math.ceil(rows.length / pageSize));
|
||||
const value = (item) => (item && typeof item === "object" ? "[바이너리]" : String(item ?? ""));
|
||||
const html =
|
||||
`<p>${esc(name)} · ${rows.length}행 · ${page + 1}/${pages}쪽</p>` +
|
||||
`<p>원본 표의 내용만 표시합니다. 웹 기성 회차에 적용하거나 원본 파일을 수정하지 않습니다.</p>` +
|
||||
warnings.map((warning) => `<p>${esc(warning)}</p>`).join("") +
|
||||
`<button data-accom-page="-1" ${page === 0 ? "disabled" : ""}>이전</button>` +
|
||||
`<button data-accom-page="1" ${page + 1 >= pages ? "disabled" : ""}>다음</button>` +
|
||||
`<div style="overflow:auto;max-height:60vh"><table class="sheet"><thead><tr>${fields.map((field) => `<th>${esc(field)}</th>`).join("")}</tr></thead><tbody>${rows
|
||||
.slice(page * pageSize, (page + 1) * pageSize)
|
||||
.map(
|
||||
(row) =>
|
||||
`<tr>${fields.map((field) => `<td>${esc(value(row[field]))}</td>`).join("")}</tr>`,
|
||||
)
|
||||
.join("")}</tbody></table></div>`;
|
||||
const dialog = document.getElementById("dialog");
|
||||
if (dialog.open && document.getElementById("dialog-title").textContent !== title)
|
||||
throw Error("다른 창을 닫은 뒤 기성 파일을 다시 확인하세요.");
|
||||
if (dialog.open) document.getElementById("dialog-content").innerHTML = html;
|
||||
else showDialog(title, html);
|
||||
}
|
||||
document.addEventListener("change", async (event) => {
|
||||
const input = event.target;
|
||||
if (input.dataset.accomInspect === undefined || !input.files?.length) return;
|
||||
const file = input.files[0];
|
||||
try {
|
||||
if (file.size > 256 * 1024 * 1024) throw Error("파일은 256MB 이하만 열 수 있습니다.");
|
||||
const response = await fetch("/api/inspect-accom", { method: "POST", body: file });
|
||||
const result = await response.json();
|
||||
if (!response.ok) throw Error(result.error || "기성 파일을 읽지 못했습니다.");
|
||||
current = { ...result, name: file.name };
|
||||
page = 0;
|
||||
render();
|
||||
} catch (error) {
|
||||
if (document.getElementById("dialog").open) alert(error.message);
|
||||
else EstApp.showDialog("기성 파일 확인 오류", EstApp.esc(error.message));
|
||||
} finally {
|
||||
input.value = "";
|
||||
}
|
||||
});
|
||||
document.addEventListener("click", (event) => {
|
||||
const button = event.target.closest("[data-accom-page]");
|
||||
if (!button || !current) return;
|
||||
page = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
Math.ceil(current.rows.length / pageSize) - 1,
|
||||
page + Number(button.dataset.accomPage),
|
||||
),
|
||||
);
|
||||
render();
|
||||
});
|
||||
return { render };
|
||||
})();
|
||||
@@ -296,7 +296,9 @@ const EstNative = (() => {
|
||||
num(tables.EstMainDat[i].CalcIndex) < 2 &&
|
||||
(num(tables.EstMainDat[i].LinkIndex) === 57 ||
|
||||
(num(tables.EstMainDat[i].LinkIndex) === 99 &&
|
||||
num(tables.EstMainDat[i].AnnualSourceLink) === 57))
|
||||
(num(tables.EstMainDat[i].AnnualSourceLink) === 57 ||
|
||||
(num(tables.EstMainDat[i].CalcIndex) === 1 &&
|
||||
num(tables.EstMainDat[i].LinkIndex_) === 57))))
|
||||
)
|
||||
procurementFees += num(tables.EstMainDat[i].TC);
|
||||
}
|
||||
|
||||
@@ -130,6 +130,7 @@ const EstExtra = (() => {
|
||||
let body = '<p class="empty">회차를 추가하세요.</p>',
|
||||
tools =
|
||||
button("add-round", "기성 회차 추가") +
|
||||
'<label>원본 기성 표 확인 <input type="file" accept=".estx" data-accom-inspect></label>' +
|
||||
(project.progress.length ? button("clear-progress", "기성 전체 초기화") : "");
|
||||
if (round) {
|
||||
tools +=
|
||||
|
||||
@@ -8,6 +8,7 @@ const { importEstx } = require("./import-estx.cjs");
|
||||
const exchange = require("./exchange.cjs");
|
||||
const { exportEstx } = require("./export-estx.cjs");
|
||||
const jdl = require("./jdl.cjs");
|
||||
const { inspectAccom } = require("./inspect-accom.cjs");
|
||||
const base = __dirname,
|
||||
port = Number(process.env.EST_TEST_PORT || 5175),
|
||||
host = process.env.EST_TEST_HOST || "100.65.141.15";
|
||||
@@ -62,6 +63,7 @@ const allowed = new Set([
|
||||
"native-estimate.js",
|
||||
"native-basic-prices.js",
|
||||
"native-extra.js",
|
||||
"native-accom-view.js",
|
||||
"native-settings.js",
|
||||
"native-report.js",
|
||||
"native-forms.js",
|
||||
@@ -163,6 +165,10 @@ const server = http.createServer(async (req, res) => {
|
||||
reply(res, 200, importEstx(bytes, name));
|
||||
return;
|
||||
}
|
||||
if (req.method === "POST" && url.pathname === "/api/inspect-accom") {
|
||||
reply(res, 200, inspectAccom(await body(req)));
|
||||
return;
|
||||
}
|
||||
if (req.method === "POST" && url.pathname === "/api/import-xlsx") {
|
||||
reply(res, 200, exchange.readWorkbook(await body(req)));
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user