feat(est-test): 내역·일위대가 단가 고르기 창
- 원본 검색 창(TFRMSERCHPROJECT)처럼 7종 자료를 검색어로 찾아 여러 개 골라 넣기 - 원본 데이타 변경 확인(TFRMDBLCLICKRTN)처럼 선택 행 연결 바꾸기, 같은 코드 전체 변경 선택 - 넣거나 바꾼 행만 원천 단가 재조회, 실패 시 되돌림 - 시험 test_est_pick.py 추가 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -426,6 +426,9 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD
|
||||
- [ ] 원본 메뉴·단추 대조(CAD 제외 약 880개)에서 나온 핵심 미구현 처리. 대조표 = 작업 폴더 `tmp/est-audit/gap_*.md`.
|
||||
- [x] 처리기만 있고 단추가 없던 동작 4개 연결 — 레벨 일괄 증가·감소(LEVEL 메뉴), 원가계산서 초기화(원가계산서 메뉴), 설계변경 초기화(설계변경 화면). 처리기마다 단추가 있는지 보는 지킴 시험 `test_est_action_bindings.py` 추가.
|
||||
- 검증완료: 옛 코드에서 시험 실패(4개 검출)·새 코드 통과, `test_est_row_actions.py` 포함 16개 통과. ORCA 브라우저에서 두 행 레벨 2→3→2 왕복·되돌리기 복원, 원가 초기화 확인창, 당초 없음 차단 문구 확인.
|
||||
- [x] 단가 고르기(원본 TFRMSERCHPROJECT·TFRMDBLCLICKRTN) — 내역서·일위대가에서 7종 자료 검색·여러 개 선택·선택 행 아래 넣기, 선택 행 연결 바꾸기(변경 전후 확인·같은 코드 전체 변경). 넣거나 바꾼 행만 원천 단가 재조회, 실패 시 되돌림. 자기 일위대가 순환·제목·요율 행 차단.
|
||||
- 검증완료: `test_est_pick.py` 5개 통과(검색·넣기·단가 반영·바꾸기·차단·일위대가 경로). ORCA 브라우저에서 보통인부 넣기 → 노무단가 172,068 반영, 경유로 바꾸기 → 재료단가 1,376 반영, 되돌리기 2회로 76행·도급액 79,603,000 복원.
|
||||
- 남음: 단가산출 산출근거(LineData) 행 넣기, 원본 「Main값우선」 선택, 기계경비 상세 행.
|
||||
|
||||
- [x] 연차 수수료의 원본 연결값 `LinkIndex_`가 57로 보존된 경우 ESTX 재열기에서 원가 계산에 반영하고, 없는 경우 저장 차단을 유지한다.
|
||||
- 자체 검증: 설치 sample의 조달수수료 2건을 연차 사본으로 저장하고 관급 수수료 제외 옵션을 켠 상태에서 재열어 원가 전 항목과 인덱스 바이트가 일치함을 확인. 기존 연결값이 없는 경우 저장 거부. 연차·기성·인덱스 관련 통합 회귀 43개 통과. `99`·고정 행·기존 연결값 `57` 조합에만 적용하며 원본 EST 실행 비교는 하지 못했다.
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
|
||||
단가산출 화면의 `편집 → 산출근거 문구 바꾸기`는 현재 호표 또는 전체 산출근거에서 문구를 일괄 변경합니다. 결과값·연결 단가 등의 계산 표지는 치환하지 않으며, 자동계산이 켜져 있으면 변경 후 재계산합니다. 실행 취소로 복원할 수 있습니다.
|
||||
|
||||
내역서·일위대가 화면의 `편집 → 단가 고르기`는 자재·노임·기계경비·일위대가·단가산출·견적·실적 자료를 검색어로 찾아 선택 행 아래에 넣거나, 선택 행 하나의 연결을 바꿉니다. 여러 개를 골라 한 번에 넣을 수 있고, 바꿀 때는 변경 전·후를 확인한 뒤 같은 코드를 쓰는 이 표의 다른 행도 함께 바꿀지 고릅니다. 넣거나 바꾼 행만 원천 단가를 다시 읽습니다. 단가산출 산출근거 행은 대상이 아닙니다.
|
||||
|
||||
내역서 편집 메뉴의 `내역 수량 초기화`는 선택한 상세 행 또는 모든 상세 행의 수량을 0으로 바꿉니다. 비율·합계 자동행은 건드리지 않으며 실행 취소할 수 있습니다.
|
||||
|
||||
원천 자료에 연결된 내역 또는 일위대가 상세 행의 결과비목을 재료·노무·경비로 바꾸면 현재 단가 합계를 유지해 지정한 비목으로 옮깁니다. 비목별 구성으로 되돌릴 때 원천 단가의 합계가 현재 단가와 다르거나 원천이 불명확하면 임의로 복원하지 않고 오류를 표시합니다. 자동계산이 꺼져 있으면 금액은 자동 재계산을 켤 때까지 유지합니다.
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
<script defer src="native-exchange.js"></script>
|
||||
<script defer src="native-c3r.js"></script>
|
||||
<script defer src="native-block-actions.js"></script>
|
||||
<script defer src="native-pick.js"></script>
|
||||
<script defer src="native-actions.js"></script>
|
||||
<script defer src="native-business-actions.js"></script>
|
||||
<script defer src="native-app.js"></script>
|
||||
@@ -88,6 +89,9 @@
|
||||
><button data-action="block-del">블럭 삭제</button>
|
||||
<hr />
|
||||
<button data-action="find">찾기</button>
|
||||
<button data-action="pick-open">
|
||||
단가 고르기(자재·노임·기계·일위·단가산출·견적·실적)
|
||||
</button>
|
||||
<button data-action="uses-data-search">데이터 사용위치 찾기</button>
|
||||
<button data-action="replace-cost-words">산출근거 문구 바꾸기</button>
|
||||
<button data-action="set-cost-line-kind">산출근거 비목 지정/해제</button>
|
||||
|
||||
@@ -597,7 +597,10 @@ const EstBusinessActions = (() => {
|
||||
});
|
||||
},
|
||||
);
|
||||
} else if (EstBlockActions.handle(name, state)) {
|
||||
} else if (
|
||||
EstBlockActions.handle(name, state) ||
|
||||
(typeof EstPick !== "undefined" && EstPick.handle(name, state))
|
||||
) {
|
||||
EstApp.render();
|
||||
} else if (name === "load-contract" || name === "load-bid") {
|
||||
purpose = name;
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
// 원본 TFRMSERCHPROJECT(검색어·여러개선택·동시검색·적용)와 TFRMDBLCLICKRTN(데이타 변경 확인)을 옮긴 단가 고르기.
|
||||
// 내역서·일위대가 행에 기초자료를 골라 넣거나 선택 행의 연결을 바꾼다. 단가산출 산출근거(LineData)는 대상이 아니다.
|
||||
const EstPick = (() => {
|
||||
const kinds = [
|
||||
[5, "StuffcDat", "자재"],
|
||||
[6, "LaborcDat", "노임"],
|
||||
[105, "MachTitleDat", "기계경비"],
|
||||
[103, "HeadTitleDat", "일위대가"],
|
||||
[104, "CostTitleDat", "단가산출"],
|
||||
[101, "EstiMateDat", "견적"],
|
||||
[21, "SilJukDat", "실적"],
|
||||
];
|
||||
const views = ["EstMainDat", "HeadMainDat"];
|
||||
const limit = 300;
|
||||
const text = (value) =>
|
||||
String(value ?? "")
|
||||
.normalize("NFKC")
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, "");
|
||||
const amount = (row) => {
|
||||
const total = Number(row.TC) || 0;
|
||||
return total || ["NC", "GC", "JC", "SC"].reduce((sum, key) => sum + (Number(row[key]) || 0), 0);
|
||||
};
|
||||
|
||||
function items(project, kind) {
|
||||
const list = [];
|
||||
for (const [k, table, label] of kinds) {
|
||||
if (kind && k !== kind) continue;
|
||||
for (const row of project.tables[table] || [])
|
||||
if (row.Code && Number(row.Index ?? 1) >= 0)
|
||||
list.push({
|
||||
kind: k,
|
||||
label,
|
||||
Code: row.Code,
|
||||
Name: row.Name ?? "",
|
||||
Size: row.Size ?? "",
|
||||
Unite: row.Unite ?? "",
|
||||
price: amount(row),
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
function search(project, kind, query) {
|
||||
const words = String(query || "")
|
||||
.split(/\s+/)
|
||||
.map(text)
|
||||
.filter(Boolean);
|
||||
const found = items(project, kind).filter((item) => {
|
||||
const haystack = text(`${item.Code} ${item.Name} ${item.Size}`);
|
||||
return words.every((word) => haystack.includes(word));
|
||||
});
|
||||
return { rows: found.slice(0, limit), total: found.length };
|
||||
}
|
||||
|
||||
function guard(view, group, picks) {
|
||||
if (!views.includes(view))
|
||||
throw Error("내역서·일위대가 화면에서만 단가를 골라 넣을 수 있습니다.");
|
||||
if (!picks.length) throw Error("넣을 자료를 하나 이상 고르세요.");
|
||||
if (view === "HeadMainDat" && picks.some((p) => p.kind === 103 && p.Code === group))
|
||||
throw Error("일위대가 자신을 하위 자료로 넣을 수 없습니다.");
|
||||
}
|
||||
|
||||
function linked(base, pick) {
|
||||
return {
|
||||
...base,
|
||||
Code: pick.Code,
|
||||
Name: pick.Name,
|
||||
Size: pick.Size,
|
||||
Unite: pick.Unite,
|
||||
LinkIndex: pick.kind,
|
||||
};
|
||||
}
|
||||
|
||||
function insert(project, view, at, group, picks) {
|
||||
guard(view, group, picks);
|
||||
const rows = project.tables[view];
|
||||
const added = [];
|
||||
for (const pick of picks) {
|
||||
const row = linked(EstModel.newRow(view, rows, project), pick);
|
||||
rows.splice(at + 1 + added.length, 0, row);
|
||||
added.push(at + 1 + added.length);
|
||||
}
|
||||
try {
|
||||
refresh(project, view, added);
|
||||
} catch (error) {
|
||||
rows.splice(at + 1, added.length);
|
||||
throw error;
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
function refresh(project, view, indices) {
|
||||
// 내역·일위대가 행은 요청한 행만 원천 단가를 다시 읽는다(행 비목 지정과 같은 경로).
|
||||
let failures = null;
|
||||
const done = EstNative.calculate(
|
||||
EstModel.validate(structuredClone(project)),
|
||||
view === "HeadMainDat"
|
||||
? { refreshHeadRows: indices, onHeadRefresh: (errors) => (failures = errors) }
|
||||
: { refreshEstimateRows: indices, onEstimateRefresh: (errors) => (failures = errors) },
|
||||
);
|
||||
if (!failures) throw Error("고른 자료의 단가를 다시 읽지 못했습니다.");
|
||||
if (failures.length) throw Error(failures.join(" / "));
|
||||
for (const index of indices)
|
||||
for (const key of EstMath.units)
|
||||
project.tables[view][index][key] = done.tables[view][index][key];
|
||||
}
|
||||
|
||||
function replaceable(target) {
|
||||
if (
|
||||
!target ||
|
||||
Number(target.Index) > 0 ||
|
||||
[54, 55, 57, 58, 61, 62, 67, 68].includes(Number(target.LinkIndex))
|
||||
)
|
||||
throw Error("일반 내역 행 하나를 선택하세요. 제목·요율·순공사비 행은 바꿀 수 없습니다.");
|
||||
}
|
||||
|
||||
function replace(project, view, index, group, pick, all) {
|
||||
guard(view, group, [pick]);
|
||||
const rows = project.tables[view],
|
||||
target = rows[index];
|
||||
replaceable(target);
|
||||
const old = { kind: Number(target.LinkIndex) || 0, Code: target.Code };
|
||||
const changed = [],
|
||||
backup = structuredClone(rows);
|
||||
rows.forEach((row, i) => {
|
||||
const same =
|
||||
i === index ||
|
||||
(all &&
|
||||
old.Code &&
|
||||
row.Code === old.Code &&
|
||||
Number(row.LinkIndex) === old.kind &&
|
||||
Number(row.Index ?? 0) <= 0);
|
||||
if (!same) return;
|
||||
Object.assign(row, linked({}, pick));
|
||||
changed.push(i);
|
||||
});
|
||||
try {
|
||||
refresh(project, view, changed);
|
||||
} catch (error) {
|
||||
project.tables[view] = backup;
|
||||
throw error;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
function cell(value) {
|
||||
return EstApp.esc(value);
|
||||
}
|
||||
|
||||
function results(state) {
|
||||
const pick = state.pick,
|
||||
{ rows, total } = search(state.project, pick.kind, pick.query);
|
||||
pick.rows = rows;
|
||||
const body = rows
|
||||
.map(
|
||||
(item, i) =>
|
||||
`<tr><td><input type="checkbox" name="pick-row" value="${i}"></td><td>${cell(item.label)}</td><td>${cell(item.Code)}</td><td style="text-align:left">${cell(item.Name)}</td><td>${cell(item.Size)}</td><td>${cell(item.Unite)}</td><td style="text-align:right">${EstApp.number(item.price)}</td></tr>`,
|
||||
)
|
||||
.join("");
|
||||
return `<div style="max-height:380px;overflow-y:auto;border:1px solid #ccc"><table class="sheet" style="width:100%;font-size:12px"><thead><tr><th></th><th>구분</th><th>코드</th><th>품명</th><th>규격</th><th>단위</th><th>단가</th></tr></thead><tbody>${body}</tbody></table></div><p>찾은 자료 ${total}건${total > limit ? ` 중 앞 ${limit}건 표시 — 검색어를 더 넣으세요` : ""}</p>`;
|
||||
}
|
||||
|
||||
function open(state) {
|
||||
if (!views.includes(state.view))
|
||||
throw Error("내역서·일위대가 화면에서만 단가를 골라 넣을 수 있습니다.");
|
||||
state.pick ||= { kind: 0, query: "" };
|
||||
const options = [[0, "전체 동시검색"], ...kinds.map(([k, , label]) => [k, label])]
|
||||
.map(
|
||||
([k, label]) =>
|
||||
`<option value="${k}" ${k === state.pick.kind ? "selected" : ""}>${cell(label)}</option>`,
|
||||
)
|
||||
.join("");
|
||||
EstApp.showDialog(
|
||||
"단가 고르기",
|
||||
`<div class="form-fields"><label>자료<select name="pick-kind">${options}</select></label><label>검색어<input name="pick-query" value="${cell(state.pick.query)}"></label></div><button data-action="pick-search">검색</button><div id="pick-results">${results(state)}</div><button data-action="pick-insert">선택 행 아래에 넣기</button> <button data-action="pick-replace">선택 행을 이것으로 바꾸기</button>`,
|
||||
);
|
||||
}
|
||||
|
||||
function chosen(state) {
|
||||
return [...document.querySelectorAll('#dialog-content [name="pick-row"]:checked')].map(
|
||||
(node) => state.pick.rows[Number(node.value)],
|
||||
);
|
||||
}
|
||||
|
||||
function confirmReplace(target, pick) {
|
||||
const line = (row) =>
|
||||
`${row.Name || ""} / ${row.Size || ""} / ${row.Unite || ""}${row.Code ? ` [${row.Code}]` : ""}`;
|
||||
return confirm(
|
||||
`데이타 변경 확인\n변경 전: ${line(target)}\n변경 후: ${line(pick)}\n\n변경하시겠습니까?`,
|
||||
);
|
||||
}
|
||||
|
||||
function handle(name, state) {
|
||||
if (name === "pick-open") open(state);
|
||||
else if (name === "pick-search") {
|
||||
state.pick.kind = Number(document.querySelector('#dialog-content [name="pick-kind"]').value);
|
||||
state.pick.query = document.querySelector('#dialog-content [name="pick-query"]').value;
|
||||
EstApp.byId("pick-results").innerHTML = results(state);
|
||||
} else if (name === "pick-insert") {
|
||||
const picks = chosen(state),
|
||||
rows = state.project.tables[state.view];
|
||||
guard(state.view, state.group, picks);
|
||||
const group = EstNative.groups(rows).get(state.group);
|
||||
const at =
|
||||
[...state.selection].sort((a, b) => a - b).at(-1) ??
|
||||
(group ? group.start + group.rows.length - 1 : rows.length - 1);
|
||||
EstApp.byId("dialog").close();
|
||||
EstApp.change((project) => {
|
||||
state.selection = new Set(insert(project, state.view, at, state.group, picks));
|
||||
});
|
||||
state.notice = `${picks.length}개 자료를 넣었습니다.`;
|
||||
} else if (name === "pick-replace") {
|
||||
const picks = chosen(state),
|
||||
index = [...state.selection][0];
|
||||
if (picks.length !== 1 || state.selection.size !== 1)
|
||||
throw Error("바꿀 행 하나와 자료 하나를 고르세요.");
|
||||
const target = state.project.tables[state.view][index];
|
||||
guard(state.view, state.group, picks);
|
||||
replaceable(target);
|
||||
if (!confirmReplace(target, picks[0])) return true;
|
||||
const all =
|
||||
Boolean(target.Code) &&
|
||||
confirm(
|
||||
`같은 코드(${target.Code})를 쓰는 이 표의 다른 행도 함께 바꾸시겠습니까?\n[취소] 현재 행만 변경`,
|
||||
);
|
||||
EstApp.byId("dialog").close();
|
||||
let changed = [];
|
||||
EstApp.change((project) => {
|
||||
changed = replace(project, state.view, index, state.group, picks[0], all);
|
||||
});
|
||||
state.notice = `${changed.length}개 행의 연결을 바꿨습니다.`;
|
||||
} else return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
return { kinds, items, search, insert, replace, handle };
|
||||
})();
|
||||
@@ -57,6 +57,7 @@ const allowed = new Set([
|
||||
"native-actions.js",
|
||||
"native-business-actions.js",
|
||||
"native-block-actions.js",
|
||||
"native-pick.js",
|
||||
"native-issues.js",
|
||||
"native-attachments.js",
|
||||
"native-standard.js",
|
||||
|
||||
Reference in New Issue
Block a user