Files
Aislo/tools/est-test/native-pick.js
eomsangdonandClaude Opus 5.5 d25183e89d feat(est-test): 내역·일위대가 단가 고르기 창
- 원본 검색 창(TFRMSERCHPROJECT)처럼 7종 자료를 검색어로 찾아 여러 개 골라 넣기
- 원본 데이타 변경 확인(TFRMDBLCLICKRTN)처럼 선택 행 연결 바꾸기, 같은 코드 전체 변경 선택
- 넣거나 바꾼 행만 원천 단가 재조회, 실패 시 되돌림
- 시험 test_est_pick.py 추가

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 14:36:34 +09:00

239 lines
9.3 KiB
JavaScript

// 원본 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 };
})();