diff --git a/resources/tester/test_est_jdl_special.py b/resources/tester/test_est_jdl_special.py index e8a106577..41396e2e8 100644 --- a/resources/tester/test_est_jdl_special.py +++ b/resources/tester/test_est_jdl_special.py @@ -91,3 +91,54 @@ def test_jdl_special_machine_detail_is_fixed_only_when_amounts_match(): assert "상세 산식 미변환" in result["before"][6]["Name"] assert any("특수 기계 상세" in warning for warning in result["warnings"]) assert not result["errors"] + + +def test_jdl_machine_percent_detail_round_trips_fixed_amounts(): + project = run("math", "EstNative.empty()") + script = """const fs=require('fs'),j=require('./tools/est-test/jdl.cjs'),a=require('./tools/est-test/archive.cjs'),schema=require('./tools/est-test/jdl-schema.json'),s=require('./tools/est-test/server.cjs'); + const empty=JSON.parse(fs.readFileSync(0,'utf8')),blank=structuredClone(empty); + const table=(name,rows)=>Buffer.from(rows.map(r=>schema[name].map(f=>r[f.name]??(f.kind===6?'0':'')).join('\\t')+'\\x05\\r\\n').join('')); + const files=new Map([['Global.ini',Buffer.from('[BASIC]')],['Main.txt',Buffer.from('0\\tSample\\t02\\x05\\r\\n')], + ['junit.txt',table('junit.txt',[{F_CODE:'J1',F_DES:'machine',F_MPRICE:5},{F_CODE:'J2',F_DES:'ambiguous',F_MPRICE:50}])], + ['jpart.txt',table('jpart.txt',[{F_CODE:'J1',F_SEQ:'1',F_DES:'percent',F_UNIT:'%',F_QQTY:10,F_MPRICE:50,F_SPROPER:'1'}, + {F_CODE:'J2',F_SEQ:'1',F_DES:'uncertain',F_UNIT:'%',F_QQTY:1,F_MPRICE:50}])]]); + const p=j.read(a.pack(files),empty),before=structuredClone(p.tables.MachMainDat);p.options.AutoCalc=true; + p.tables.MachMainDat[1].GC=6;const q=s.calculate(p),out=j.output(q),entries=a.unpack(out.bytes),back=j.read(out.bytes,blank); + process.stdout.write(JSON.stringify({before,parts:j.parse('jpart.txt',entries.get('jpart.txt')), + rows:back.tables.MachMainDat,errors:q.calculation.errors}));""" + result = json.loads( + subprocess.check_output( + ["node", "-e", script], input=json.dumps(project).encode(), cwd=ROOT + ) + ) + assert result["before"][1]["GC"] == 5 + assert result["before"][1]["GN"] == 10 + assert result["before"][1]["JdlSourceQuantity"] == 10 + assert result["parts"][0]["F_UNIT"] == "%" + assert result["parts"][0]["F_QQTY"] == 100 + assert result["rows"][1]["GC"] == 6 + assert "상세 산식 미변환" in result["before"][-1]["Name"] + assert not result["errors"] + + +def test_jdl_fixed_unit_percent_exports_a_consistent_quantity(): + project = run("math", "EstNative.empty()") + script = """const fs=require('fs'),j=require('./tools/est-test/jdl.cjs'),a=require('./tools/est-test/archive.cjs'); + const p=JSON.parse(fs.readFileSync(0,'utf8')),empty=structuredClone(p);p.name='percent'; + p.tables.HeadTitleDat=[{Code:'U',Name:'unit',GC:10,TC:10}]; + p.tables.HeadMainDat=[{Index:1,Code:'U'}, + {Index:0,Name:'fixed percent',Unite:'%',CalcIndex:1,JdlSummary:true,GC:10,TC:10}]; + p.tables.EstMainDat=[{Code:'U',Name:'unit',LinkIndex:103,GN:2,GU:10,GC:20,TC:20},{LinkIndex:54}]; + const out=j.output(p),files=a.unpack(out.bytes),parts=j.parse('unitpart.txt',files.get('unitpart.txt')), + q=j.read(out.bytes,empty); + process.stdout.write(JSON.stringify({part:parts[0],row:q.tables.HeadMainDat[1],total:q.tables.EstMainDat.at(-1).TC}));""" + result = json.loads( + subprocess.check_output( + ["node", "-e", script], input=json.dumps(project).encode(), cwd=ROOT + ) + ) + assert result["part"]["F_UNIT"] == "%" + assert result["part"]["F_QQTY"] == 100 + assert result["part"]["F_MPRICE"] == result["part"]["F_MCOST"] == 10 + assert result["row"]["GC"] == 10 + assert result["total"] == 20 diff --git a/resources/tester/test_est_text_parser.py b/resources/tester/test_est_text_parser.py index d46594ec3..1827b27a9 100644 --- a/resources/tester/test_est_text_parser.py +++ b/resources/tester/test_est_text_parser.py @@ -49,6 +49,26 @@ def test_redundant_price_caption_keeps_quantity_and_source_updates(): assert result["mismatch"]["errors"] +def test_component_total_ignores_following_numeric_substitution(): + result = run( + "math", + """(() => { + const rows = [ + {Index:1, Code:'D1'}, + {Index:0, LineData:'LL = 5'}, + {Index:0, SCheck:-3, LineData:'100 = \\x12100\\x12'}, + {Index:0, SCheck:-3, LineData:'\\x1a경비 : 100\\x1a ÷ LL = 100 ÷ 5 = \\x1220\\x12'} + ]; + const first=EstText.calculate(rows);rows[1].LineData='LL = 4';rows[2].LineData='120 = \\x12100\\x12'; + return {first,changed:EstText.calculate(rows)}; + })()""", + ) + assert result["first"]["errors"] == [] + assert result["first"]["rows"][3]["TC"] == 20 + assert result["changed"]["errors"] == [] + assert result["changed"]["rows"][3]["TC"] == 30 + + def test_equals_in_description_is_not_a_formula_separator(): result = run( "math", diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md index 421280df1..592b99728 100644 --- a/tools/est-test/PLAN.md +++ b/tools/est-test/PLAN.md @@ -423,8 +423,17 @@ 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] 비목 합계 수식에 뒤따르는 숫자 대입식을 구분하고 앞선 비목·변수 변경 전파와 원본 회귀를 확인한다. + - 자체 검증: 비목 100→120과 변수 5→4 변경 시 합계 20→30 전파. 설치 ESTX 24개 비교에서 산식 오류 14→13건, 저장 결과 일치 114,379→114,381건, 파일별 일치 감소 없음. 원본 실행 비교는 미실시. + +- [x] JDL 일위대가의 퍼센트 고정 금액 출력에서 수량과 금액의 계산 관계를 일치시키고 재가져오기를 검증한다. + - 자체 검증: 고정 금액 10을 100%×10으로 출력하여 수량·단가·저장 금액 일치, 재가져온 내역 합계 20 확인. JDL·업무 시험 47개 통과. 외부 JDL 인증은 미확인. + +- [x] JDL 기계경비 퍼센트 상세의 적용 금액이 호표 합계와 명확히 일치하면 고정 금액으로 편집·재출력한다. 모호한 경우 합계 보존을 유지한다. + - 자체 검증: 수량 10%와 단가 50의 금액 5를 편집 금액 6으로 바꿔 JDL 출력·재가져오기 유지. 모호한 1% 사례는 기존 합계만 보존하며, 원본 수량은 첫 가져오기 화면에 표시한다. JDL·업무·오류 검색 59개 및 최종 JDL 회귀 27개 통과. 원래 특수 산식과 외부 인증은 미확인. + - [x] JDL 기계경비의 특수 상세도 저장 금액 합계가 명확히 호표와 일치하면 개별 고정 금액으로 가져와 편집·출력한다. 특수 산식은 해석하지 않는다. - - 자체 검증: 합성 JDL의 일반·특수 상세 120을 각각 불러와 특수 금액 변경 후 합계 130, JDL 재가져오기 유지. 합계 불일치·퍼센트 행은 기존 호표 합계 경로 유지, 수량 2의 합계 일치 사례 확인. JDL·업무·오류 검사 58개 통과. 특수 산식·외부 JDL 인증은 미확인. 이후 산식·JDL·업무·오류 검색 통합 회귀 107개 통과. + - 자체 검증: 합성 JDL의 일반·특수 상세 120을 각각 불러와 특수 금액 변경 후 합계 130, JDL 재가져오기 유지. 합계 불일치·모호한 퍼센트 행은 기존 호표 합계 경로 유지, 수량 2의 합계 일치 사례 확인. JDL·업무·오류 검사 58개 통과. 특수 산식·외부 JDL 인증은 미확인. 이후 산식·JDL·업무·오류 검색 통합 회귀 107개 통과. - [x] JDL 특수 일위 상세의 저장 비목 합계가 호표와 정확히 일치하면 상세별 고정 금액으로 가져와 편집·출력한다. 불일치나 행 식별이 모호한 경우 기존 합계 보존을 유지한다. - 자체 검증: 정상·특수 상세 합계 350인 합성 JDL에서 특수 상세 금액 변경 후 360으로 내역까지 재계산·JDL 왕복. 원천이 없는 특수 행의 저장 금액 보존, 합계 불일치 시 기존 합계 경로 유지. JDL·업무·오류 검사 57개 통과. 특수 산식과 외부 JDL 인증은 미확인. diff --git a/tools/est-test/README.md b/tools/est-test/README.md index cbd94672b..c89669174 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -50,7 +50,7 @@ - 일위대가에 연결된 기계·실적·견적·단가산출 자료도 일반 상세 조건을 만족하면 JDL로 출력합니다. 기계 상세가 변환되지 않거나 그 외 복합 원천은 고정 단가로 전개합니다. - JDL 일위대가의 고정 금액 상세와 저장 합계 차액은 별도 단가로 내보냅니다. 다시 가져온 정수 금액은 해당 별도 단가에 연결된 일반 행으로 표시될 수 있습니다. - 특수 일위 상세라도 행별 저장 금액의 합계가 호표와 맞고 행 식별이 가능하면 각 금액을 고정해 편집할 수 있습니다. 특수 산식은 원본 JDL에 보존하며 변환하지 않습니다. -- 특수 기계 상세도 수량과 금액의 해석이 명확히 합계와 맞는 경우 개별 고정 금액으로 표시합니다. 퍼센트 행과 합계가 불일치하는 구성은 호표 합계를 보존합니다. +- 특수 기계 상세와 퍼센트 행도 수량 적용 금액이 호표 합계와 명확히 맞는 경우 개별 고정 금액으로 표시합니다. 해석이 모호하거나 합계가 불일치하면 호표 합계를 보존합니다. - 단가산출 호표의 저장된 비목별 금액은 JDL 단가산출 자료로 내보내 내역·일위 연결을 유지합니다. 원래 산출근거의 식은 JDL에서 복원되지 않습니다. - **원본 구조 유지 ESTX 수정 저장**: 가져온 파일의 공사명·계산 옵션과 행 수 및 인덱스 키가 유지된 단가·수량·문구·금액, 기존 BLOB과 원가 서식을 기록하고 재읽기로 검산합니다. 기존 수량산출서의 이름과 항목을 수정하고, 확인된 형식의 항목과 단일 결과 표지 산출 행을 추가·삭제할 수 있습니다. 고유한 일반 행 삭제와 조건에 맞는 산출 행 이동도 지원합니다. 연결된 항목이나 중복·복합 표지, 불명확한 서식 구조는 보호를 위해 일부 작업을 거부합니다. 기존 서식 구간의 텍스트를 편집할 때 새 글에는 변경 시작 위치의 서식이 적용됩니다. 원본 결과가 기존 계산과 모두 일치하면 수정된 계산 결과 표지와 수량 목록을 함께 갱신합니다. 불일치나 계산 오류가 있으면 저장을 거부합니다. 기존 BLOB은 공간이 부족하면 블록을 추가합니다. 원본 파일을 덮어쓰지 않으며 `.idx`와 CAD 바이너리를 보존합니다. 새 수량산출서, 인덱스 키 변경, 수량의 내역 연결 변경, 웹 기성·연차·당초 사본은 ESTX 저장을 지원하지 않아 JSON으로 보관합니다. 원본 EST 실행 검증은 못했습니다. - **예정공정표**: 내역 행을 공정으로 일괄 추가하고 내역 금액으로 가중치를 갱신할 수 있습니다. 코드가 없거나 같은 코드가 반복된 행은 내역의 행 식별자로 구분합니다. 기존 공정의 기간·진도·이름은 유지합니다. 공정별 기간 막대와 입력 진도의 가중평균을 표시하며, 날짜 역전·잘못된 진도를 차단합니다. 공휴일/공기 산정 기준을 임의 적용하지 않습니다. diff --git a/tools/est-test/jdl-machine-export.cjs b/tools/est-test/jdl-machine-export.cjs index 1bad7d80b..ed405acf3 100644 --- a/tools/est-test/jdl-machine-export.cjs +++ b/tools/est-test/jdl-machine-export.cjs @@ -43,7 +43,7 @@ function exportMachines(project, tables, map, ambiguous, num, next, source, warn F_DES: row.Name, F_SIZE: row.Size, F_UNIT: row.Unite, - F_QQTY: 1, + F_QQTY: ["%", "%"].includes(row.Unite) ? 100 : 1, F_MPRICE: num(row.GC), F_LPRICE: num(row.NC), F_EPRICE: num(row.JC), diff --git a/tools/est-test/jdl-machine-parts.cjs b/tools/est-test/jdl-machine-parts.cjs index 9c81be78b..423cd7127 100644 --- a/tools/est-test/jdl-machine-parts.cjs +++ b/tools/est-test/jdl-machine-parts.cjs @@ -9,4 +9,8 @@ function ordinaryMachinePart(part, num) { ); } -module.exports = { ordinaryMachinePart }; +function machineQuantity(part, num) { + return num(part.F_QQTY) / (["%", "%"].includes(part.F_UNIT) ? 100 : 1); +} + +module.exports = { ordinaryMachinePart, machineQuantity }; diff --git a/tools/est-test/jdl-unit-part.cjs b/tools/est-test/jdl-unit-part.cjs index f293df3d1..07b34b9b4 100644 --- a/tools/est-test/jdl-unit-part.cjs +++ b/tools/est-test/jdl-unit-part.cjs @@ -14,7 +14,7 @@ function exportUnitPart(row, code, index, map, source, num, next) { F_DES: row.Name, F_SIZE: row.Size, F_UNIT: row.Unite, - F_QQTY: fixed ? 1 : num(row.GN), + F_QQTY: fixed ? (["%", "%"].includes(row.Unite) ? 100 : 1) : num(row.GN), F_MPRICE: num(fixed ? row.GC : row.GU), F_LPRICE: num(fixed ? row.NC : row.NU), F_EPRICE: num(fixed ? row.JC : row.JU) + num(fixed ? row.SC : row.SU), diff --git a/tools/est-test/jdl.cjs b/tools/est-test/jdl.cjs index 5b08bbbd8..caf4822f6 100644 --- a/tools/est-test/jdl.cjs +++ b/tools/est-test/jdl.cjs @@ -7,7 +7,7 @@ const { exportMachines } = require("./jdl-machine-export.cjs"); const { orderUnits } = require("./jdl-unit-order.cjs"); const { isFixedPart, exportUnitPart } = require("./jdl-unit-part.cjs"); const { canPreserveSpecial, fixedSpecialPart } = require("./jdl-special-unit.cjs"); -const { ordinaryMachinePart } = require("./jdl-machine-parts.cjs"); +const { ordinaryMachinePart, machineQuantity } = require("./jdl-machine-parts.cjs"); const decoder = new TextDecoder("euc-kr", { fatal: true }); function fingerprint(project) { const { originalJdlState, id, ...content } = project; @@ -360,9 +360,7 @@ function read(bytes, project) { const identifiable = parts.length > 0 && uniqueSequences.size === parts.length && - parts.every( - (part) => Boolean(part.F_SEQ) && num(part.F_QQTY) > 0 && !["%", "%"].includes(part.F_UNIT), - ); + parts.every((part) => Boolean(part.F_SEQ) && num(part.F_QQTY) > 0); const ordinary = identifiable && parts.every((part) => ordinaryMachinePart(part, num)); const matches = (weighted) => [ @@ -373,14 +371,14 @@ function read(bytes, project) { ([field, value]) => Math.abs( parts.reduce( - (sum, part) => sum + num(part[field]) * (weighted ? num(part.F_QQTY) : 1), + (sum, part) => sum + num(part[field]) * (weighted ? machineQuantity(part, num) : 1), 0, ) - value, ) < 1e-6, ); const direct = identifiable && matches(false); const weighted = identifiable && !direct && matches(true); - const basic = (direct && parts.every((part) => num(part.F_QQTY) === 1)) || weighted; + const basic = (direct && parts.every((part) => machineQuantity(part, num) === 1)) || weighted; project.tables.MachMainDat.push({ PkID: project.tables.MachMainDat.length + 1, Index: 1, @@ -396,7 +394,7 @@ function read(bytes, project) { machineWeighted ||= weighted; machineSpecial ||= !ordinary; for (const part of parts) { - const factor = weighted ? num(part.F_QQTY) : 1, + const factor = weighted ? machineQuantity(part, num) : 1, GC = num(part.F_MPRICE) * factor, NC = num(part.F_LPRICE) * factor, JC = num(part.F_EPRICE) * factor; @@ -410,7 +408,7 @@ function read(bytes, project) { Name: part.F_DES, Size: part.F_SIZE, Unite: part.F_UNIT, - GN: 1, + GN: num(part.F_QQTY), GC, NC, JC, @@ -435,7 +433,9 @@ function read(bytes, project) { "기계 상세는 저장 금액별로 표시하며, 산식과 원천 단가 연결은 원본 JDL에 보존됩니다.", ); if (machineWeighted) - unitWarnings.push("일부 기계 상세는 수량×단가가 저장 합계와 일치해 계산 금액으로 표시합니다."); + unitWarnings.push( + "일부 기계 상세는 단위에 따른 수량×단가가 저장 합계와 일치해 계산 금액으로 표시합니다.", + ); if (machineSpecial) unitWarnings.push("특수 기계 상세는 저장 금액으로 고정하며 특수 산식은 원문에 보존합니다."); if (machineFallback) diff --git a/tools/est-test/native-text.js b/tools/est-test/native-text.js index 3d4e92712..e80a27772 100644 --- a/tools/est-test/native-text.js +++ b/tools/est-test/native-text.js @@ -318,6 +318,7 @@ const EstText = (() => { .trim(); if (body.includes(":")) body = body.slice(body.indexOf(":") + 1); body = body.replace(/^\s*[A-Za-z]\w*\s*=\s*/, ""); + body = body.split("=")[0]; calculated = body ? EstExpression.evaluate(body, (key) => variables[key]) : 0; } } else if (matched && sc === -5) {