feat(est-test): 수량 항목 끝수 처리 선택
- 원본 수량목록 자리수 메뉴처럼 AUTO·절사·올림·반올림 0~5자리를 항목별로 지정 - 결과 코드와 산출 행 결과 표지를 함께 바꾸고 중복 코드 거부 - 새 항목 코드가 끝수 붙은 코드와 겹치던 오류 수정 - 끝수 변경 작업의 ESTX 저장은 명확한 문구로 거부 - 연결 내역코드 칸 선택 상자가 옆 칸을 덮던 배치 수정 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
"""EST 테스트 앱 수량 항목 끝수 처리(원본 AUTO·절사·올림·반올림 0~5)."""
|
||||
|
||||
from test_est_rea_parity import run
|
||||
|
||||
SHEET = """const sheet={Code:'N1',Name:'시험',options:{digits:2,roundMode:2},
|
||||
items:[{Code:'r000000',Name:'가'},{Code:'r000001',Name:'나'}],
|
||||
rows:[{LineData:' 가 1.2345×1 = \\x121.23\\x12',markers:['r000000']},
|
||||
{LineData:' 나 2.0001×1 = \\x122\\x12',markers:['r000001']}]};"""
|
||||
|
||||
|
||||
def test_format_renames_item_and_marker_and_changes_result():
|
||||
result = run(
|
||||
"math",
|
||||
"(()=>{"
|
||||
+ SHEET
|
||||
+ """
|
||||
const auto=EstQuantity.calculate(sheet).items[0].GN;
|
||||
EstQuantity.setResultFormat(sheet,0,3,1);
|
||||
const up=EstQuantity.calculate(sheet).items[0].GN;
|
||||
const code=sheet.items[0].Code, marker=sheet.rows[0].markers[0];
|
||||
EstQuantity.setResultFormat(sheet,0,1,3);
|
||||
const half=EstQuantity.calculate(sheet).items[0].GN;
|
||||
EstQuantity.setResultFormat(sheet,0,0);
|
||||
return {auto,up,code,marker,half,back:sheet.items[0].Code,backMarker:sheet.rows[0].markers[0],
|
||||
other:sheet.items[1].Code};
|
||||
})()""",
|
||||
)
|
||||
assert result == {
|
||||
"auto": 1.23,
|
||||
"up": 1.3,
|
||||
"code": "r00000031",
|
||||
"marker": "r00000031",
|
||||
"half": 1.235,
|
||||
"back": "r000000",
|
||||
"backMarker": "r000000",
|
||||
"other": "r000001",
|
||||
}
|
||||
|
||||
|
||||
def test_format_rejects_bad_digits_collision_and_unknown_code():
|
||||
result = run(
|
||||
"math",
|
||||
"(()=>{"
|
||||
+ SHEET
|
||||
+ """
|
||||
const out=[];
|
||||
const t=(fn)=>{try{fn();out.push('ok');}catch(e){out.push(e.message.slice(0,6));}};
|
||||
t(()=>EstQuantity.setResultFormat(sheet,0,2,6));
|
||||
sheet.items[1].Code='r00000021'; sheet.rows[1].markers=['r00000021'];
|
||||
t(()=>EstQuantity.setResultFormat(sheet,0,2,1));
|
||||
sheet.items.push({Code:'X1'});
|
||||
t(()=>EstQuantity.setResultFormat(sheet,2,2,1));
|
||||
return {out,unchanged:sheet.items[0].Code};
|
||||
})()""",
|
||||
)
|
||||
assert result["unchanged"] == "r000000"
|
||||
assert [m[:2] for m in result["out"]] == ["자리", "같은", "이 "]
|
||||
|
||||
|
||||
def test_new_item_code_skips_base_used_by_formatted_code():
|
||||
result = run(
|
||||
"math",
|
||||
"(()=>{"
|
||||
+ SHEET
|
||||
+ """
|
||||
EstQuantity.setResultFormat(sheet,0,2,3);
|
||||
return EstQuantity.nextItemCode(sheet);
|
||||
})()""",
|
||||
)
|
||||
assert result == "r000002"
|
||||
|
||||
|
||||
def test_estx_export_refuses_format_change_with_clear_message():
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
source = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx")
|
||||
if not source.exists():
|
||||
pytest.skip("installed EST main sample unavailable")
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
script = """
|
||||
const fs=require('fs'), path=process.argv[1];
|
||||
const imp=require('./tools/est-test/import-estx.cjs'), q=require('./tools/est-test/native-quantity.cjs');
|
||||
const ex=require('./tools/est-test/export-estx.cjs'), p=imp.importEstx(fs.readFileSync(path));
|
||||
const used=(s,c)=>s.rows.some(r=>(r.markers||[]).includes(c));
|
||||
const s=p.quantitySheets.find(s=>s.items.some(i=>/^r\d{6}$/.test(i.Code)&&used(s,i.Code)));
|
||||
q.setResultFormat(s,s.items.findIndex(i=>/^r\d{6}$/.test(i.Code)&&used(s,i.Code)),1,3);
|
||||
let message='';
|
||||
try { ex.exportEstx(p); } catch (e) { message=e.message; }
|
||||
process.stdout.write(JSON.stringify(message));
|
||||
"""
|
||||
out = subprocess.check_output(["node", "-e", script, str(source)], cwd=root)
|
||||
assert json.loads(out.decode("utf-8")).startswith("수량 항목 끝수 처리 변경은 ESTX")
|
||||
@@ -429,6 +429,9 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD
|
||||
- [x] 단가 고르기(원본 TFRMSERCHPROJECT·TFRMDBLCLICKRTN) — 내역서·일위대가에서 7종 자료 검색·여러 개 선택·선택 행 아래 넣기, 선택 행 연결 바꾸기(변경 전후 확인·같은 코드 전체 변경). 넣거나 바꾼 행만 원천 단가 재조회, 실패 시 되돌림. 자기 일위대가 순환·제목·요율 행 차단.
|
||||
- 검증완료: `test_est_pick.py` 5개 통과(검색·넣기·단가 반영·바꾸기·차단·일위대가 경로). ORCA 브라우저에서 보통인부 넣기 → 노무단가 172,068 반영, 경유로 바꾸기 → 재료단가 1,376 반영, 되돌리기 2회로 76행·도급액 79,603,000 복원.
|
||||
- 남음: 단가산출 산출근거(LineData) 행 넣기, 원본 「Main값우선」 선택, 기계경비 상세 행.
|
||||
- [x] 수량 항목 끝수 처리(원본 TFRMMULLANG mnuAuto·mnuTrunc·mnuUp·mnuRound 0~5) — 결과 코드 뒤 방식+자리수(1 반올림·2 절사·3 올림)로 항목·결과 표지 함께 바꿈, 중복 코드 거부. 새 항목 코드가 끝수 붙은 코드의 앞 7자리와 겹치던 오류 수정. ESTX 수정 저장은 명확한 문구로 거부. 연결 내역코드 칸의 선택 상자가 옆 칸을 덮던 배치 수정.
|
||||
- 검증완료: `test_est_quantity_format.py` 4개(1.2345 → AUTO 1.23·올림1 1.3·반올림3 1.235·AUTO 복귀, 거부 3종, 코드 생성, 설치 main.estx 저장 거부 문구), 수량·ESTX 저장 시험 포함 50개 통과. ORCA 브라우저에서 선택 상자로 올림 1자리 → 재계산 1.3·코드 r00000031 확인, 샘플 다시 열어 원상 복구.
|
||||
- 남음: 끝수 변경의 ESTX 저장(원본 항목 코드 키 변경 — 인덱스 재생성 필요).
|
||||
|
||||
- [x] 연차 수수료의 원본 연결값 `LinkIndex_`가 57로 보존된 경우 ESTX 재열기에서 원가 계산에 반영하고, 없는 경우 저장 차단을 유지한다.
|
||||
- 자체 검증: 설치 sample의 조달수수료 2건을 연차 사본으로 저장하고 관급 수수료 제외 옵션을 켠 상태에서 재열어 원가 전 항목과 인덱스 바이트가 일치함을 확인. 기존 연결값이 없는 경우 저장 거부. 연차·기성·인덱스 관련 통합 회귀 43개 통과. `99`·고정 행·기존 연결값 `57` 조합에만 적용하며 원본 EST 실행 비교는 하지 못했다.
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
|
||||
내역서·일위대가 화면의 `편집 → 단가 고르기`는 자재·노임·기계경비·일위대가·단가산출·견적·실적 자료를 검색어로 찾아 선택 행 아래에 넣거나, 선택 행 하나의 연결을 바꿉니다. 여러 개를 골라 한 번에 넣을 수 있고, 바꿀 때는 변경 전·후를 확인한 뒤 같은 코드를 쓰는 이 표의 다른 행도 함께 바꿀지 고릅니다. 넣거나 바꾼 행만 원천 단가를 다시 읽습니다. 단가산출 산출근거 행은 대상이 아닙니다.
|
||||
|
||||
수량산출 화면의 수량 항목마다 `끝수 처리`에서 AUTO(산출서 기본)·절사·올림·반올림 0~5자리를 고릅니다. 원본처럼 결과 코드 뒤에 방식과 자리수를 붙이며(`r000001` → `r00000131`), 산출 행의 결과 표지도 함께 바꿉니다. 이 변경이 있는 작업은 ESTX 수정 저장을 거부하고 JSON 저장을 안내합니다.
|
||||
|
||||
내역서 편집 메뉴의 `내역 수량 초기화`는 선택한 상세 행 또는 모든 상세 행의 수량을 0으로 바꿉니다. 비율·합계 자동행은 건드리지 않으며 실행 취소할 수 있습니다.
|
||||
|
||||
원천 자료에 연결된 내역 또는 일위대가 상세 행의 결과비목을 재료·노무·경비로 바꾸면 현재 단가 합계를 유지해 지정한 비목으로 옮깁니다. 비목별 구성으로 되돌릴 때 원천 단가의 합계가 현재 단가와 다르거나 원천이 불명확하면 임의로 복원하지 않고 오류를 표시합니다. 자동계산이 꺼져 있으면 금액은 자동 재계산을 켤 때까지 유지합니다.
|
||||
|
||||
@@ -438,6 +438,16 @@ function updateQuantities(project, base, cp949, memory) {
|
||||
}
|
||||
if (!row?.Editor?.binary || !same(row.Editor, oldRow.Editor))
|
||||
throw Error("수량산출서 원본 편집기 데이터 충돌");
|
||||
const resultBase = (code) => String(code ?? "").match(/^([LrG]\d{6})(?:[123]\d)?$/)?.[1];
|
||||
if (
|
||||
originals[i].items.some(
|
||||
(item) =>
|
||||
resultBase(item.Code) &&
|
||||
!sheets[i].items.some((next) => next.Code === item.Code) &&
|
||||
sheets[i].items.some((next) => resultBase(next.Code) === resultBase(item.Code)),
|
||||
)
|
||||
)
|
||||
throw Error("수량 항목 끝수 처리 변경은 ESTX 저장을 지원하지 않습니다. JSON으로 저장하세요.");
|
||||
const removed = new Set(
|
||||
originals[i].items
|
||||
.filter((item) => !sheets[i].items.some((next) => next.Code === item.Code))
|
||||
|
||||
@@ -315,13 +315,64 @@ 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-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></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><input data-quantity-target="${index}" value="${esc(item.TargetCode ?? item.LinkCode ?? "")}" aria-label="연결 내역코드">${targetSelect(state.project, item, "sheet", 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-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>`;
|
||||
}
|
||||
// 원본 수량목록 자리수 메뉴(mnuAuto·mnuTrunc·mnuUp·mnuRound): 결과 코드 뒤 방식(1 반올림·2 절사·3 올림)+자리수.
|
||||
function resultFormat(code) {
|
||||
const match = String(code ?? "").match(/^([LrG]\d{6})(?:([123])(\d))?$/);
|
||||
return match
|
||||
? {
|
||||
base: match[1],
|
||||
method: Number(match[2] || 0),
|
||||
digits: match[3] ? Number(match[3]) : null,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
function setResultFormat(sheet, index, method, digits) {
|
||||
const item = sheet.items[index],
|
||||
parsed = resultFormat(item?.Code);
|
||||
if (!parsed) throw Error("이 수량 항목은 결과 자리수를 지정할 수 없는 코드입니다.");
|
||||
if (![0, 1, 2, 3].includes(method)) throw Error("끝수 처리 방식을 고르세요.");
|
||||
if (method && !(Number.isInteger(digits) && digits >= 0 && digits <= 5))
|
||||
throw Error("자리수는 0~5만 지정할 수 있습니다.");
|
||||
const old = item.Code,
|
||||
code = method ? `${parsed.base}${method}${digits}` : parsed.base;
|
||||
if (code === old) return code;
|
||||
if (
|
||||
sheet.items.some((other, i) => i !== index && other.Code === code) ||
|
||||
sheet.rows.some((row) => (row.markers || []).includes(code))
|
||||
)
|
||||
throw Error(`같은 결과 코드가 이미 있습니다: ${code}`);
|
||||
item.Code = code;
|
||||
for (const row of sheet.rows)
|
||||
if (row.markers) row.markers = row.markers.map((marker) => (marker === old ? code : marker));
|
||||
return code;
|
||||
}
|
||||
function formatSelect(item, index) {
|
||||
const parsed = resultFormat(item.Code);
|
||||
if (!parsed) return "";
|
||||
const current = parsed.method ? `${parsed.method}:${parsed.digits}` : "0";
|
||||
const options = [["0", "AUTO(산출서 기본)"]];
|
||||
for (const [method, label] of [
|
||||
[2, "절사"],
|
||||
[3, "올림"],
|
||||
[1, "반올림"],
|
||||
])
|
||||
for (let digits = 0; digits <= 5; digits++)
|
||||
options.push([`${method}:${digits}`, `${label} ${digits}자리`]);
|
||||
return `<select data-quantity-format="${index}" aria-label="끝수 처리">${options
|
||||
.map(
|
||||
([value, label]) =>
|
||||
`<option value="${value}" ${value === current ? "selected" : ""}>${label}</option>`,
|
||||
)
|
||||
.join("")}</select>`;
|
||||
}
|
||||
function nextItemCode(sheet) {
|
||||
const codes = new Set([
|
||||
...sheet.items.map((item) => item.Code),
|
||||
...sheet.rows.flatMap((row) => row.markers || []),
|
||||
]);
|
||||
const codes = new Set(
|
||||
[...sheet.items.map((item) => item.Code), ...sheet.rows.flatMap((row) => row.markers || [])]
|
||||
.filter(Boolean)
|
||||
.map((code) => resultFormat(code)?.base || code),
|
||||
);
|
||||
for (let index = 0; index < 1000000; index++) {
|
||||
const code = `r${String(index).padStart(6, "0")}`;
|
||||
if (!codes.has(code)) return code;
|
||||
@@ -385,6 +436,17 @@ const EstQuantity = (() => {
|
||||
(sheet.rows[Number(node.dataset.quantityLine)].LineData = EstText.encode(node.value)),
|
||||
false,
|
||||
);
|
||||
if (node.dataset.quantityFormat !== undefined) {
|
||||
const [method, digits] = node.value.split(":").map(Number);
|
||||
try {
|
||||
EstApp.change(() =>
|
||||
setResultFormat(sheet, Number(node.dataset.quantityFormat), method, digits),
|
||||
);
|
||||
} catch (error) {
|
||||
state.notice = error.message;
|
||||
EstApp.render();
|
||||
}
|
||||
}
|
||||
if (node.dataset.quantityTarget !== undefined)
|
||||
EstApp.change(() => {
|
||||
const item = sheet.items[Number(node.dataset.quantityTarget)];
|
||||
@@ -613,6 +675,9 @@ const EstQuantity = (() => {
|
||||
excelUpdates,
|
||||
targetSelect,
|
||||
newCode,
|
||||
resultFormat,
|
||||
nextItemCode,
|
||||
setResultFormat,
|
||||
selector,
|
||||
render,
|
||||
install,
|
||||
|
||||
Reference in New Issue
Block a user