feat(est): link quantity to individual rows

This commit is contained in:
2026-11-07 07:51:42 +09:00
parent b6d98ac9d5
commit b279b964ce
6 changed files with 89 additions and 20 deletions
@@ -136,3 +136,27 @@ def test_new_quantity_code_does_not_reuse_existing_or_referenced_codes():
EstQuantity.newCode([{Code:'Q2',formula:'1'}]), EstQuantity.newCode([])]""",
)
assert result == ["Q3", "Q1", "Q1"]
def test_target_row_ids_support_blank_and_duplicate_codes_for_both_modes():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.tables.EstMainDat=[
{PkID:1,Code:'X',GN:1},{PkID:2,Code:'X',GN:2},{PkID:3,Code:'',GN:3}];
p.quantities=[{Code:'A',formula:'7',TargetCode:'X',TargetKey:'id:2'},
{Code:'B',formula:'4',TargetCode:'',TargetKey:'id:3'}];
EstQuantity.applyTotals(p,EstQuantity.totals(p,'manual'));
const manual=p.tables.EstMainDat.map(row=>row.GN);
p.quantitySheets=[{Code:'Q',options:{},rows:[{LineData:'2+3 = \\x120\\x12',markers:['r1']}],
items:[{Code:'r1',GN:0,TargetCode:'X',TargetKey:'id:1'}]}];
p.tables.EstMainDat.reverse();EstQuantity.applyTotals(p,EstQuantity.totals(p,'Q'));
const sheet=p.tables.EstMainDat.map(row=>[row.PkID,row.GN]);
p.quantitySheets[0].items[0].TargetKey='id:missing';let error='';
try{EstQuantity.totals(p,'Q')}catch(e){error=e.message}
return {manual,sheet,error};
})()""",
)
assert result["manual"] == [1, 7, 4]
assert result["sheet"] == [[3, 4], [2, 7], [1, 5]]
assert result["error"]
+1 -1
View File
@@ -25,7 +25,7 @@ Ctrl+S는 입력 중인 셀도 확정한 뒤 저장합니다. 연속 저장 요
초기 자료는 기술정보 DB의 실제 REA 저장 샘플입니다. `REA 샘플 대조`에서 원본 저장값과 275개 계산 결과를 비교합니다. 기존 ESTX의 기본 수량 연결은 문서·항목 코드와 내역 코드가 유일한 경우 화면에 표시하며, 중복·다른 모드는 원문에 보존하고 경고합니다. 원본 연결 데이터 실자료 검증과 새 연결의 ESTX 쓰기는 아직 지원하지 않습니다. 테스트용 임의 요율을 원본 기본값으로 넣지 않습니다. 새 원가 서식의 요율은 사용자가 입력합니다.
수량산출서에서 내역에 수량을 반영할 때 연결 코드에 해당하는 상세 내역이 정확히 하나여야 합니다. 코드가 없거나 중복되면 다른 항목을 포함해 반영을 중단하고 오류를 표시합니다.
수량산출서와 수식표에서 내역 행을 직접 선택해 연결할 수 있습니다. 내역 행 선택을 사용하면 코드가 없거나 중복되어도 해당 행에만 수량을 반영합니다. 코드를 직접 입력할 때는 해당 상세 내역이 정확히 하나여야 하며, 대상이 없거나 중복되면 다른 항목을 포함해 반영을 중단하고 오류를 표시합니다.
수량 화면에서 새 산출서를 만들고 이름과 산출 항목의 품명·규격을 편집할 수 있습니다. `산출 행 추가`로 결과 표지가 연결된 행과 항목을 함께 만든 뒤 수식을 입력하고, 연결 내역코드를 지정해 계산 수량을 내역에 반영합니다. 행 아래 삽입, 위·아래 이동과 산출 행·산출서 삭제도 가능하며, 이 구조 변경은 JSON 저장과 다시 열기를 지원합니다. 기존 연결은 내역코드를 비워 해제할 수 있습니다.
+6 -5
View File
@@ -545,11 +545,12 @@ const EstActions = (() => {
if (node.matches("[data-extra]")) {
const value = node.type === "number" ? Number(node.value) : node.value;
if (node.type === "number" && !Number.isFinite(value)) throw Error("수치 입력 오류");
EstApp.change(
() =>
(project[node.dataset.extra][Number(node.dataset.row)][node.dataset.field] = value),
false,
);
EstApp.change(() => {
const row = project[node.dataset.extra][Number(node.dataset.row)];
row[node.dataset.field] = value;
if (node.dataset.extra === "quantities" && node.dataset.field === "TargetCode")
row.TargetKey = "";
}, false);
return;
}
if (node.matches("[data-table][data-field]")) {
+2 -1
View File
@@ -28,7 +28,8 @@ const EstExtra = (() => {
return [
field("quantities", index, "Code", row.Code, "text"),
field("quantities", index, "Name", row.Name, "text"),
field("quantities", index, "TargetCode", row.TargetCode, "text"),
field("quantities", index, "TargetCode", row.TargetCode, "text") +
EstQuantity.targetSelect(project, row, "manual", index),
field("quantities", index, "formula", row.formula, "text"),
field("quantities", index, "digits", row.digits ?? 2),
esc(row.Error || number(row.GN)),
+9 -1
View File
@@ -96,11 +96,19 @@ const EstForms = (() => {
};
}
if (id === 14) {
const target = (item) => {
if (!item.TargetKey) return item.TargetCode ?? item.LinkCode ?? "";
const row = project.tables.EstMainDat.find(
(entry) => entry.PkID != null && `id:${entry.PkID}` === item.TargetKey,
);
return row ? `${row.Name || "내역"} (${row.Code || item.TargetKey})` : item.TargetKey;
};
const manual = EstQuantity.manual(project.quantities || []),
rows = manual.rows.map((row) => ({
...row,
SheetName: "수식표",
formula: row.formula || "",
TargetCode: target(row),
})),
errors = [...manual.errors];
for (const sheet of project.quantitySheets || []) {
@@ -112,7 +120,7 @@ const EstForms = (() => {
...item,
SheetName: sheet.Name || sheet.Code,
formula: index < 0 ? "" : EstText.clean(calculated.rows[index].LineData),
TargetCode: item.TargetCode ?? item.LinkCode ?? "",
TargetCode: target(item),
});
}
}
+47 -12
View File
@@ -151,18 +151,34 @@ const EstQuantity = (() => {
const quantities = new Map();
for (const item of items) {
const code = item.TargetCode ?? item.LinkCode;
if (!code) continue;
const targets = project.tables.EstMainDat.filter((r) => r.Code === code && !Number(r.Level));
if (!targets.length) throw Error(`연결 내역코드 없음: ${code}`);
if (targets.length > 1) throw Error(`연결 내역코드 중복: ${code}`);
quantities.set(code, (quantities.get(code) || 0) + Number(item.GN || 0));
if (!code && !item.TargetKey) continue;
const targets = project.tables.EstMainDat.map((row, index) => ({ row, index })).filter(
({ row }) =>
!Number(row.Level) &&
(item.TargetKey
? row.PkID != null && `id:${row.PkID}` === item.TargetKey
: row.Code === code),
);
if (!targets.length) throw Error(`연결 내역 없음: ${item.TargetKey || code}`);
if (targets.length > 1) throw Error(`연결 내역 중복: ${item.TargetKey || code}`);
const index = targets[0].index;
quantities.set(index, (quantities.get(index) || 0) + Number(item.GN || 0));
}
if (!quantities.size) throw Error("연결할 내역코드를 먼저 입력하세요.");
return quantities;
}
function applyTotals(project, quantities) {
for (const row of project.tables.EstMainDat)
if (quantities.has(row.Code) && !Number(row.Level)) row.GN = quantities.get(row.Code);
for (const [index, value] of quantities) project.tables.EstMainDat[index].GN = value;
}
function targetSelect(project, item, section, index) {
const { esc } = EstApp;
const rows = project.tables.EstMainDat.filter((row) => row.PkID != null && !Number(row.Level));
return `<select data-quantity-select="${section}" data-index="${index}" aria-label="연결 내역 행"><option value="">내역 행 선택</option>${rows
.map((row) => {
const key = `id:${row.PkID}`;
return `<option value="${esc(key)}" ${item.TargetKey === key ? "selected" : ""}>${esc(row.Name || "내역")} · ${esc(row.Code || "코드 없음")} · ${esc(row.PkID)}</option>`;
})
.join("")}</select>`;
}
function newCode(items) {
const codes = new Set(items.map((row) => row.Code));
@@ -186,7 +202,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>산출 행 추가</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></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>${number(sheet.items[index].GN)}</td><td>${number(item.GN)}</td><td><input data-quantity-target="${index}" value="${esc(item.TargetCode ?? item.LinkCode ?? "")}" aria-label="연결 내역코드"></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>산출 행 추가</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></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>${number(sheet.items[index].GN)}</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></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>`;
}
function install() {
document.addEventListener("change", (event) => {
@@ -197,6 +213,23 @@ const EstQuantity = (() => {
EstApp.render();
return;
}
if (node.dataset.quantitySelect !== undefined) {
const item =
node.dataset.quantitySelect === "manual"
? state.project.quantities[Number(node.dataset.index)]
: state.project.quantitySheets?.find((s) => s.Code === state.quantitySheet)?.items[
Number(node.dataset.index)
];
if (!item) return;
const row = state.project.tables.EstMainDat.find(
(r) => r.PkID != null && `id:${r.PkID}` === node.value && !Number(r.Level),
);
EstApp.change(() => {
item.TargetKey = row ? node.value : "";
item.TargetCode = row?.Code || "";
}, false);
return;
}
const sheet = state.project.quantitySheets?.find((s) => s.Code === state.quantitySheet);
if (!sheet) return;
if (node.dataset.quantityName !== undefined)
@@ -213,10 +246,11 @@ const EstQuantity = (() => {
false,
);
if (node.dataset.quantityTarget !== undefined)
EstApp.change(
() => (sheet.items[Number(node.dataset.quantityTarget)].TargetCode = node.value),
false,
);
EstApp.change(() => {
const item = sheet.items[Number(node.dataset.quantityTarget)];
item.TargetCode = node.value;
item.TargetKey = "";
}, false);
});
document.addEventListener("click", (event) => {
const { state } = EstApp;
@@ -298,6 +332,7 @@ const EstQuantity = (() => {
manual,
totals,
applyTotals,
targetSelect,
newCode,
selector,
render,