feat(est-test): 산출 줄 수량 지정과 지정 해제
- 드래그한 숫자를 결과 표지로 감싸 수량 항목 생성, 품명·규격·단위 표지 반영 - 커서 위치 결과 해제와 미사용 항목 삭제, 내역 연결 항목은 거부 - 이 편집의 ESTX 저장은 명확한 문구로 거부 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -72,3 +72,72 @@ def test_estx_export_refuses_reordered_sheets():
|
||||
str(MAIN),
|
||||
)
|
||||
assert result.startswith("수량산출서 순서 변경은 ESTX")
|
||||
|
||||
|
||||
MARK = r"""(()=>{
|
||||
const sheet={Code:'N1',options:{digits:2,roundMode:2},items:[{Code:'r000000',Name:'가'}],
|
||||
rows:[{LineData:' \x0e잔토\x0e \x130.15\x13 2×3 = 6 \x15㎥\x15 그리고 \x125\x12',markers:['r000000']}]};
|
||||
const shown=EstText.display(sheet.rows[0].LineData);
|
||||
const at=shown.indexOf('6 ');
|
||||
const out=[];
|
||||
const t=(fn)=>{try{fn();out.push('ok');}catch(e){out.push(e.message.slice(0,6));}};
|
||||
t(()=>EstQuantitySheets.mark(sheet,0,at,at+1,'r000001'));
|
||||
const item=sheet.items.find(i=>i.Code==='r000001');
|
||||
const markers=[...sheet.rows[0].markers];
|
||||
const calc=EstQuantity.calculate(sheet).items.map(i=>[i.Code,i.GN]);
|
||||
t(()=>EstQuantitySheets.mark(sheet,0,at+4,at+5,'r000002'));
|
||||
t(()=>EstQuantitySheets.mark(sheet,0,0,3,'r000002'));
|
||||
const inside=EstText.display(sheet.rows[0].LineData).indexOf('⟦결과⟧6')+5;
|
||||
t(()=>EstQuantitySheets.free(sheet,0,inside));
|
||||
const after=EstText.display(sheet.rows[0].LineData);
|
||||
sheet.items[0].TargetCode='D1';
|
||||
const last=after.lastIndexOf('⟦결과⟧')-1;
|
||||
t(()=>EstQuantitySheets.free(sheet,0,last));
|
||||
return {out,markers,item:[item.Name,item.Size,item.Unite],calc,
|
||||
back:after===shown,items:sheet.items.map(i=>i.Code)};
|
||||
})()"""
|
||||
|
||||
|
||||
def test_mark_and_free_quantity_in_existing_line():
|
||||
files = ["native-math.js", "native-formula.js", "native-text.js", "native-quantity.cjs"]
|
||||
result = node(
|
||||
f"""
|
||||
for (const f of {json.dumps(files)})
|
||||
vm.runInContext(fs.readFileSync('tools/est-test/' + f, 'utf8'), ctx, {{ filename: f }});
|
||||
process.stdout.write(JSON.stringify(run({json.dumps(MARK)})));
|
||||
"""
|
||||
)
|
||||
assert result["markers"] == ["r000001", "r000000"]
|
||||
assert result["item"] == ["잔토", "0.15", "㎥"]
|
||||
assert ["r000001", 6] in result["calc"]
|
||||
assert result["back"] is True
|
||||
assert result["items"] == ["r000000"]
|
||||
assert [m[:2] for m in result["out"]] == ["ok", "이미", "산출", "ok", "내역"]
|
||||
|
||||
|
||||
def test_estx_export_refuses_marker_edit():
|
||||
if not MAIN.exists():
|
||||
pytest.skip("installed EST main sample unavailable")
|
||||
files = ["native-math.js", "native-formula.js", "native-text.js", "native-quantity.cjs"]
|
||||
result = node(
|
||||
f"""
|
||||
for (const f of {json.dumps(files)})
|
||||
vm.runInContext(fs.readFileSync('tools/est-test/' + f, 'utf8'), ctx, {{ filename: f }});
|
||||
const imp = require('./tools/est-test/import-estx.cjs'), ex = require('./tools/est-test/export-estx.cjs');
|
||||
ctx.project = imp.importEstx(fs.readFileSync(process.argv[1]));
|
||||
run(`(() => {{
|
||||
for (const s of project.quantitySheets)
|
||||
for (let i = 0; i < s.rows.length; i++) {{
|
||||
const code = s.rows[i].markers[0], item = s.items.find((x) => x.Code === code);
|
||||
if (!code || !item || item.TargetCode || item.TargetKey) continue;
|
||||
const t = EstText.display(s.rows[i].LineData);
|
||||
return EstQuantitySheets.free(s, i, t.indexOf('⟦결과⟧') + 4);
|
||||
}}
|
||||
}})()`);
|
||||
let message = '';
|
||||
try {{ ex.exportEstx(ctx.project); }} catch (e) {{ message = e.message; }}
|
||||
process.stdout.write(JSON.stringify(message));
|
||||
""",
|
||||
str(MAIN),
|
||||
)
|
||||
assert result.startswith("산출 줄의 수량 지정·해제는 ESTX")
|
||||
|
||||
@@ -439,10 +439,12 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD
|
||||
- 검증완료: `test_est_blank.py` 2개(세 방법·원본 불변·잘못된 방법 거부, 설치 sample.estx 공내역 ESTX 저장·재읽기에서 수량 유지·단가·금액 0·기초자료 유지), 관련 11개 통과. ORCA 브라우저에서 메뉴 → 수량만 남김 → JSON 내려받기(GN 133·TU 0·TC 0), 현재 작업 불변, 사본을 열어 합계 0 유지 확인 후 샘플 복구.
|
||||
- [x] 수량산출서 복사·순서(원본 TFRMMULLANG 공종복사·붙여넣기) — 현재 산출서 아래에 복사(새 코드·내역 연결 비움), 위로·아래로. 순서 변경 ESTX 저장은 명확한 문구로 거부. 단추 지킴 시험이 `.cjs` 화면 파일도 보도록 넓힘.
|
||||
- 검증완료: `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, 커서 두고 지정 해제 → 원래 줄·항목 복귀, 샘플 복구.
|
||||
- 대조 합계(CAD 제외 882개): 구현 283 · 부분 193 · 없음 273 · 제외 133.
|
||||
- [ ] 남은 핵심(실무 빈도순):
|
||||
1. 통합검색 — 저장된 다른 작업에서 단가·호표 찾기.
|
||||
3. 수량산출서 — 기존 줄 수량 지정·해제, 누계·4방향 합계, 제출용 전체 인쇄.
|
||||
3. 수량산출서 — 누계·4방향 합계, 제출용 전체 인쇄.
|
||||
4. Excel → EST 열 맞춤 가져오기(남의 공내역 Excel).
|
||||
5. 다른 공사 불러오기 시 딸린 일위·단가산출 함께 복사, 프로젝트 추가(합치기).
|
||||
6. 낙찰률 전체·범위·공종별 적용.
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
|
||||
`편집 → 찾기·다음찾기`(Ctrl+F)는 원본 찾기 창처럼 검색어를 처음부터 또는 현재 위치부터 찾아 해당 행으로 이동합니다. 일위대가·단가산출·기계경비는 찾은 행의 호표로 넘어갑니다. 단가산출에서는 찾은 행의 문구를 한 건씩 바꾸고 다음 위치로 이동하며, 모두 바꾸기는 `산출근거 문구 바꾸기`를 씁니다. 표 위 검색창은 걸러 보기로 그대로 남습니다.
|
||||
|
||||
산출 줄 옆 `수량 지정`은 줄 글에서 드래그해 고른 숫자를 결과 표지로 감싸고 수량 항목을 새로 만듭니다. 품명·규격·단위 표지가 있으면 항목 이름에 옮깁니다. `지정 해제`는 커서가 놓인 결과 표지를 풀고, 다른 줄에서 쓰지 않는 항목을 지웁니다. 내역에 연결된 항목은 먼저 연결을 비워야 합니다. 이 변경이 있는 작업은 ESTX 수정 저장을 거부합니다.
|
||||
|
||||
수량산출 화면의 `산출서 복사`는 현재 산출서를 바로 아래에 복사하고, 내역에 두 번 반영되지 않도록 복사본의 내역 연결을 비웁니다. `산출서 위로`·`아래로`로 순서를 바꿉니다. 산출서 추가·순서 변경이 있는 작업은 ESTX 수정 저장을 거부하고 JSON 저장을 안내합니다.
|
||||
|
||||
수량산출 화면의 수량 항목마다 `끝수 처리`에서 AUTO(산출서 기본)·절사·올림·반올림 0~5자리를 고릅니다. 원본처럼 결과 코드 뒤에 방식과 자리수를 붙이며(`r000001` → `r00000131`), 산출 행의 결과 표지도 함께 바꿉니다. 이 변경이 있는 작업은 ESTX 수정 저장을 거부하고 JSON 저장을 안내합니다.
|
||||
|
||||
@@ -418,6 +418,8 @@ function updateQuantities(project, base, cp949, memory) {
|
||||
const sheets = project.quantitySheets || [],
|
||||
originals = base.quantitySheets || [];
|
||||
if (sheets.length !== originals.length) throw Error("수량산출서 추가·삭제는 지원하지 않습니다.");
|
||||
if (sheets.some((sheet) => sheet.markerEdited))
|
||||
throw Error("산출 줄의 수량 지정·해제는 ESTX 저장을 지원하지 않습니다. JSON으로 저장하세요.");
|
||||
if (sheets.some((sheet, i) => sheet.Code !== originals[i].Code))
|
||||
throw Error("수량산출서 순서 변경은 ESTX 저장을 지원하지 않습니다. JSON으로 저장하세요.");
|
||||
const rows = project.tables.MulMainDat.map((r) => ({ ...r }));
|
||||
|
||||
@@ -602,7 +602,7 @@ 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))
|
||||
(typeof EstQuantitySheets !== "undefined" && EstQuantitySheets.handle(name, state, node))
|
||||
) {
|
||||
EstApp.render();
|
||||
} else if (name === "load-contract" || name === "load-bid") {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// 원본 수량산출(TFRMMULLANG) 공종 단위 조작 — 공종복사하기·붙여넣기(mnuCopy·mnuPaste)와 순서 이동.
|
||||
// 복사본은 현재 산출서 바로 아래에 넣고, 내역 이중 반영을 막기 위해 내역 연결을 비운다.
|
||||
// 산출 줄의 수량으로 지정·지정 해제(TFRMMULWORD popSetMulLang·popSetFree)는 화면 표기(⟦결과⟧) 위치로 다룬다.
|
||||
const EstQuantitySheets = (() => {
|
||||
function copy(project, code, uid) {
|
||||
const sheets = project.quantitySheets || [],
|
||||
@@ -30,7 +31,82 @@ const EstQuantitySheets = (() => {
|
||||
return true;
|
||||
}
|
||||
|
||||
function handle(name, state) {
|
||||
const RESULT = "⟦결과⟧";
|
||||
function tagged(shown, label) {
|
||||
const match = shown.match(new RegExp(`⟦${label}⟧([^⟦]*)⟦${label}⟧`));
|
||||
return match ? match[1].trim() : "";
|
||||
}
|
||||
function count(text) {
|
||||
return text.split(RESULT).length - 1;
|
||||
}
|
||||
|
||||
function mark(sheet, rowIndex, start, end, code, text) {
|
||||
const row = sheet.rows[rowIndex];
|
||||
if (!row) throw Error("산출 줄을 고르세요.");
|
||||
const shown = text ?? EstText.display(row.LineData);
|
||||
let from = Math.min(start, end),
|
||||
to = Math.max(start, end);
|
||||
while (from < to && /\s/.test(shown[from])) from++;
|
||||
while (to > from && /\s/.test(shown[to - 1])) to--;
|
||||
const picked = shown.slice(from, to);
|
||||
if (!/^-?(\d{1,3}(,\d{3})+|\d+)(\.\d+)?$/.test(picked))
|
||||
throw Error("산출 줄에서 수량으로 지정할 숫자만 드래그해 고르세요.");
|
||||
if (count(shown.slice(0, from)) % 2) throw Error("이미 수량으로 지정된 숫자입니다.");
|
||||
const position = count(shown.slice(0, from)) / 2;
|
||||
row.LineData = EstText.encode(
|
||||
`${shown.slice(0, from)}${RESULT}${picked}${RESULT}${shown.slice(to)}`,
|
||||
);
|
||||
row.markers = [...(row.markers || [])];
|
||||
row.markers.splice(position, 0, code);
|
||||
sheet.markerEdited = true;
|
||||
sheet.items.push({
|
||||
Code: code,
|
||||
Name: tagged(shown, "품명") || "새 항목",
|
||||
Size: tagged(shown, "규격"),
|
||||
Unite: tagged(shown, "단위"),
|
||||
GN: 0,
|
||||
TargetCode: "",
|
||||
});
|
||||
return code;
|
||||
}
|
||||
|
||||
function free(sheet, rowIndex, caret, text) {
|
||||
const row = sheet.rows[rowIndex];
|
||||
if (!row) throw Error("산출 줄을 고르세요.");
|
||||
const shown = text ?? EstText.display(row.LineData),
|
||||
pairs = [];
|
||||
for (let at = shown.indexOf(RESULT), open = -1; at >= 0; at = shown.indexOf(RESULT, at + 1))
|
||||
if (open < 0) open = at;
|
||||
else {
|
||||
pairs.push([open, at + RESULT.length]);
|
||||
open = -1;
|
||||
}
|
||||
const position = pairs.findIndex(([open, close]) => caret >= open && caret <= close);
|
||||
if (position < 0) throw Error("해제할 수량(⟦결과⟧ 안쪽)에 커서를 두세요.");
|
||||
const code = row.markers?.[position],
|
||||
item = sheet.items.find((next) => next.Code === code);
|
||||
if (item?.TargetCode || item?.TargetKey)
|
||||
throw Error("내역에 연결된 수량 항목입니다. 연결 내역코드를 먼저 비우세요.");
|
||||
const [open, close] = pairs[position];
|
||||
row.LineData = EstText.encode(
|
||||
shown.slice(0, open) +
|
||||
shown.slice(open + RESULT.length, close - RESULT.length) +
|
||||
shown.slice(close),
|
||||
);
|
||||
row.markers = row.markers.filter((_, i) => i !== position);
|
||||
sheet.markerEdited = true;
|
||||
if (!sheet.rows.some((other) => (other.markers || []).includes(code)))
|
||||
sheet.items = sheet.items.filter((next) => next.Code !== code);
|
||||
return code;
|
||||
}
|
||||
|
||||
function lineEditor(index) {
|
||||
const editor = document.querySelector(`[data-quantity-line="${index}"]`);
|
||||
if (!editor) throw Error("산출 줄을 찾을 수 없습니다.");
|
||||
return editor;
|
||||
}
|
||||
|
||||
function handle(name, state, node) {
|
||||
const code = state.quantitySheet;
|
||||
if (name === "quantity-sheet-copy") {
|
||||
EstApp.change((project) => {
|
||||
@@ -44,9 +120,22 @@ const EstQuantitySheets = (() => {
|
||||
if (index < 0 || index + step < 0 || index + step >= sheets.length)
|
||||
throw Error("더 옮길 수 없습니다.");
|
||||
EstApp.change((project) => move(project, code, step), false);
|
||||
} else if (name === "quantity-mark" || name === "quantity-free") {
|
||||
const sheet = state.project.quantitySheets?.find((next) => next.Code === code),
|
||||
index = Number(node?.dataset.index),
|
||||
editor = lineEditor(index);
|
||||
if (!sheet) throw Error("수량산출서를 고르세요.");
|
||||
const { selectionStart: start, selectionEnd: end } = editor;
|
||||
if (name === "quantity-mark" && start === end)
|
||||
throw Error("산출 줄에서 수량으로 지정할 숫자를 드래그해 고른 뒤 누르세요.");
|
||||
EstApp.change(() => {
|
||||
if (name === "quantity-mark")
|
||||
mark(sheet, index, start, end, EstQuantity.nextItemCode(sheet), editor.value);
|
||||
else free(sheet, index, start, editor.value);
|
||||
}, false);
|
||||
} else return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
return { copy, move, handle };
|
||||
return { copy, move, mark, free, handle };
|
||||
})();
|
||||
|
||||
@@ -315,7 +315,7 @@ const EstQuantity = (() => {
|
||||
{ esc, number, byId } = EstApp;
|
||||
byId("work-content").innerHTML =
|
||||
selector(state) +
|
||||
`<div class="business-tools"><label>산출서명 <input data-quantity-name value="${esc(sheet.Name)}"></label><button data-quantity-add-item>수량 항목 추가</button><button data-quantity-add>산출 행 추가</button><button data-quantity-apply>내역 수량 반영</button><button data-action="quantity-sheet-copy">산출서 복사</button><button data-action="quantity-sheet-up">산출서 위로</button><button data-action="quantity-sheet-down">산출서 아래로</button><button data-quantity-delete-sheet>산출서 삭제</button></div><table class="sheet"><thead><tr><th>산출코드</th><th>품명</th><th>규격</th><th>단위</th><th>저장 수량</th><th>재계산 수량</th><th>연결 내역코드</th><th>끝수 처리</th><th>작업</th></tr></thead><tbody>${output.items.map((item, index) => `<tr><td>${esc(item.Code)}</td><td><input data-quantity-item="Name" data-index="${index}" value="${esc(item.Name || "")}" aria-label="품명"></td><td><input data-quantity-item="Size" data-index="${index}" value="${esc(item.Size || "")}" aria-label="규격"></td><td><input data-quantity-item="Unite" data-index="${index}" value="${esc(item.Unite || "")}" aria-label="단위"></td><td>${number(sheet.items[index]?.GN ?? 0)}</td><td>${number(item.GN)}</td><td style="white-space:normal"><input data-quantity-target="${index}" value="${esc(item.TargetCode ?? item.LinkCode ?? "")}" aria-label="연결 내역코드">${targetSelect(state.project, item, "sheet", index)}</td><td>${formatSelect(sheet.items[index], index)}</td><td><button data-quantity-delete-item="${index}">삭제</button></td></tr>`).join("")}</tbody></table><p class="business-note">${esc(output.errors.join(" / "))} 산출 행의 결과 표지로 수량 항목을 연결합니다. 신규 산출서와 지원되지 않는 원본 구조 변경은 JSON으로 저장합니다.</p><table class="sheet"><thead><tr><th>줄</th><th>산출근거 편집</th><th>작업</th></tr></thead><tbody>${sheet.rows.map((row, index) => `<tr><td>${index + 1}</td><td><textarea data-quantity-line="${index}" rows="1" style="min-width:750px" aria-label="산출근거 ${index + 1}">${esc(EstText.display(output.rows[index].LineData))}</textarea></td><td><button data-quantity-add data-index="${index}">아래 추가</button><button data-quantity-move="-1" data-index="${index}" ${index === 0 ? "disabled" : ""}>↑</button><button data-quantity-move="1" data-index="${index}" ${index === sheet.rows.length - 1 ? "disabled" : ""}>↓</button><button data-quantity-delete-row="${index}">삭제</button></td></tr>`).join("")}</tbody></table>`;
|
||||
`<div class="business-tools"><label>산출서명 <input data-quantity-name value="${esc(sheet.Name)}"></label><button data-quantity-add-item>수량 항목 추가</button><button data-quantity-add>산출 행 추가</button><button data-quantity-apply>내역 수량 반영</button><button data-action="quantity-sheet-copy">산출서 복사</button><button data-action="quantity-sheet-up">산출서 위로</button><button data-action="quantity-sheet-down">산출서 아래로</button><button data-quantity-delete-sheet>산출서 삭제</button></div><table class="sheet"><thead><tr><th>산출코드</th><th>품명</th><th>규격</th><th>단위</th><th>저장 수량</th><th>재계산 수량</th><th>연결 내역코드</th><th>끝수 처리</th><th>작업</th></tr></thead><tbody>${output.items.map((item, index) => `<tr><td>${esc(item.Code)}</td><td><input data-quantity-item="Name" data-index="${index}" value="${esc(item.Name || "")}" aria-label="품명"></td><td><input data-quantity-item="Size" data-index="${index}" value="${esc(item.Size || "")}" aria-label="규격"></td><td><input data-quantity-item="Unite" data-index="${index}" value="${esc(item.Unite || "")}" aria-label="단위"></td><td>${number(sheet.items[index]?.GN ?? 0)}</td><td>${number(item.GN)}</td><td style="white-space:normal"><input data-quantity-target="${index}" value="${esc(item.TargetCode ?? item.LinkCode ?? "")}" aria-label="연결 내역코드">${targetSelect(state.project, item, "sheet", index)}</td><td>${formatSelect(sheet.items[index], index)}</td><td><button data-quantity-delete-item="${index}">삭제</button></td></tr>`).join("")}</tbody></table><p class="business-note">${esc(output.errors.join(" / "))} 산출 행의 결과 표지로 수량 항목을 연결합니다. 신규 산출서와 지원되지 않는 원본 구조 변경은 JSON으로 저장합니다.</p><table class="sheet"><thead><tr><th>줄</th><th>산출근거 편집</th><th>작업</th></tr></thead><tbody>${sheet.rows.map((row, index) => `<tr><td>${index + 1}</td><td><textarea data-quantity-line="${index}" rows="1" style="min-width:750px" aria-label="산출근거 ${index + 1}">${esc(EstText.display(output.rows[index].LineData))}</textarea></td><td><button data-quantity-add data-index="${index}">아래 추가</button><button data-quantity-move="-1" data-index="${index}" ${index === 0 ? "disabled" : ""}>↑</button><button data-quantity-move="1" data-index="${index}" ${index === sheet.rows.length - 1 ? "disabled" : ""}>↓</button><button data-action="quantity-mark" data-index="${index}">수량 지정</button><button data-action="quantity-free" data-index="${index}">지정 해제</button><button data-quantity-delete-row="${index}">삭제</button></td></tr>`).join("")}</tbody></table>`;
|
||||
}
|
||||
// 원본 수량목록 자리수 메뉴(mnuAuto·mnuTrunc·mnuUp·mnuRound): 결과 코드 뒤 방식(1 반올림·2 절사·3 올림)+자리수.
|
||||
function resultFormat(code) {
|
||||
|
||||
Reference in New Issue
Block a user