feat(est-test): 적용대가 보기 변경·수량불러오기·적용대가집계표

- 물가변동 품목조정 화면을 native-applied-cost.js 로 옮김(native-extra.js 700줄 한계)
- 보기 변경: 단가 밴드 숨기고 계약·구분별 수량 7종만
- 수량불러오기: 고른 내역서 행 i 수량을 구분 칸에, 원가 행 i Cost 를 Es*Cost 에(위치 짝)
- 적용대가집계표: 원가 행마다 계약·적용제외·적용대가 금액

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-12 18:59:17 +09:00
co-authored by Claude Opus 5.5
parent 67cb82d8d2
commit 7b765c2599
7 changed files with 327 additions and 75 deletions
+1
View File
@@ -34,6 +34,7 @@ for (const file of [
"native-verify.js",
"native-block-actions.js",
"native-extra.js",
"native-applied-cost.js",
"native-business-actions.js",
"native-forms.js",
"native-excel-to-est.js",
+86
View File
@@ -0,0 +1,86 @@
"""적용대가 분류 내역서: 수량불러오기(위치 짝)·적용대가집계표·보기 변경."""
from test_est_rea_parity import run
def test_gn_load_copies_quantity_by_position_and_wonga_cost():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.options.SourceRefresh=false;
p.tables.EstMainDat=[{Code:'A',GN:10,GU:80},{Code:'B',GN:20,GU:50},{Code:'C',GN:5,GU:10}];
p.tables.Wonga=[{Name:'재료비',Cost:0},{Name:'노무비',Cost:0}];
const src={tables:{EstMainDat:[{GN:3},{GN:7}],Wonga:[{Cost:111},{Cost:222},{Cost:333}]}};
const n=EstAppliedCost.load(p,3,src);
const q=EstBusiness.baseline(EstNative.calculate(structuredClone(p)));
EstAppliedCost.load(q,0,{tables:{EstMainDat:[{GN:9}],Wonga:[{Cost:500}]}});
const r=structuredClone(p);delete r.baseline;
EstAppliedCost.load(r,0,{tables:{EstMainDat:[{GN:4}]}});
return {n,actual:p.tables.EstMainDat.map(x=>x.EscActual??null),
work:p.tables.Wonga.map(x=>x.EsWorkCost),base:q.baseline.tables.EstMainDat[0].GN,
contract:q.tables.Wonga[0].EsContractCost,old:r.tables.EstMainDat[0].GN_};
})()""",
)
assert result == {
"n": 2,
"actual": [3, 7, None],
"work": [111, 222],
"base": 9,
"contract": 500,
"old": 4,
}
def test_summary_uses_loaded_contract_cost_or_contract_calculation():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.options.SourceRefresh=false;p.options.EscDesignTotal=1000;
p.tables.EstMainDat=[{Code:'A',Name:'a',GN:10,GU:100},{LinkIndex:54}];
p.tables.Wonga=[{Name:'재료비',VSusic:'',BaseField:'GC',LinkIndex:1}];
const q=EstBusiness.baseline(EstNative.calculate(p));
q.tables.EstMainDat[0].EscActual=4;
const a=EstAppliedCost.summary(q).map(x=>[x.contract,x.except,x.apply]);
q.tables.Wonga[0].EsContractCost=2000;
const b=EstAppliedCost.summary(q).map(x=>[x.contract,x.except,x.apply]);
return {a,b};
})()""",
)
contract, except_, apply = result["a"][0]
assert apply == contract - except_
assert result["b"][0][0] == 2000
assert result["b"][0][2] == 2000 - result["b"][0][1]
def test_style_toggle_shows_quantity_band_only():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.options.SourceRefresh=false;
p.tables.EstMainDat=[{Code:'A',Name:'a',GN:10,GU:100,EscPS:2}];
const q=EstBusiness.baseline(EstNative.calculate(p));
const state={project:q,view:'escalation',escalationPage:0,selection:new Set()};
const box=(t,tools,body)=>tools+body,button=(a,t)=>`<button data-action="${a}">${t}</button>`,
table=(h,rows)=>JSON.stringify({h,rows});
const g=globalThis;g.EstApp={...(g.EstApp||{}),esc:String,number:String,render(){}};g.EstViews={cell:(t,i,k,v)=>String(v)};
const plain=EstAppliedCost.view(state,q,{box,button,table});
EstAppliedCost.handle('applied-style-toggle',state);
const band=EstAppliedCost.view(state,q,{box,button,table});
const head=(s)=>JSON.parse(s.slice(s.indexOf('{"h"'))).h;
return {plain:head(plain).length,band:head(band)};
})()""",
)
assert result["plain"] == 18
assert result["band"] == [
"코드",
"품명",
"계약 수량",
"시공중 수량",
"예정공정 수량",
"실행공정 수량",
"기성 수량",
"PS 수량",
"선시공 수량",
"적용제외",
"적용수량",
]
+1
View File
@@ -21,6 +21,7 @@
<script defer src="native-misc-tools.js"></script>
<script defer src="native-excel-to-est.js"></script>
<script defer src="native-other-load.js"></script>
<script defer src="native-applied-cost.js"></script>
<script defer src="native-actions-more.js"></script>
<script defer src="native-wonga.js"></script>
<script defer src="native-jungsang.js"></script>
+1
View File
@@ -7,6 +7,7 @@ const EstActionsMore = (() => {
typeof EstMiscTools !== "undefined" && EstMiscTools,
typeof EstExcelToEst !== "undefined" && EstExcelToEst,
typeof EstOtherLoad !== "undefined" && EstOtherLoad,
typeof EstAppliedCost !== "undefined" && EstAppliedCost,
].filter(Boolean);
function handle(name, state, node) {
for (const module of modules()) if (module.handle(name, state, node)) return true;
+236
View File
@@ -0,0 +1,236 @@
// 원본 「물가변동 적용대가 분류 내역서」(TfrmAppliedCost — spec_pb1 §5·§6) 화면: 품목조정 표(native-extra.js 에서 옮김),
// 「적용대가 보기 변경」(cmStyleToggle 0x10b5918 → e43b48: 단가 밴드를 숨기고 계약·구분별 수량 7종만 — 값 변화 없음),
// 「수량불러오기」(cmGNLoad 0x10b57a4 → 10b6630·10b5934·10b617c: 고른 내역서의 행 i 수량 GN 을 이 내역 행 i 의 구분 칸에,
// 그 파일 원가 행 i 의 Cost 를 원가 행 i 의 Es*Cost 에 — 위치 짝, 원본 행이 없으면 건너뜀),
// 「적용대가집계표」(mnuESWonga → TfrmESPWonga: 원가 행마다 계약금액·적용제외금액·적용대가금액 — 계산은 품목조정과 같은 값).
const EstAppliedCost = (() => {
const num = (v) => Number(v) || 0;
const QTY = ["EscConstruction", "EscPlanned", "EscActual", "EscProgress", "EscPS", "EscBefore"];
const QTY_LABELS = [
"시공중 수량",
"예정공정 수량",
"실행공정 수량",
"기성 수량",
"PS 수량",
"선시공 수량",
];
const KINDS = [
"계약분 수량",
"시공중인공사계약분 수량",
"예정공정율분 수량",
"실행공정율분 수량",
"기성율분 수량",
"PS분 수량",
"신시공부분 수량",
];
const COST = [
"EsContractCost",
"EsSiGongCost",
"EsSchduleCost",
"EsWorkCost",
"EsACCost",
"EsPSCost",
"EsBeforeCost",
];
function view(state, project, { box, button, table }) {
const { esc, number } = EstApp;
const output = EstBusiness.escalation(project);
state.business = output;
const comp = state.escalationComponent || "NU";
const choices = EstMath.units
.map(
(key, i) =>
`<option value="${key}" ${key === comp ? "selected" : ""}>${["노무", "재료", "경비", "실적"][i]}</option>`,
)
.join("");
const page = EstBlockActions.escalationPage(state, project.tables.EstMainDat);
const qty = state.escQtyView;
const rows = page.rows.map(({ row, index }) => {
const result = output.rows[index];
if (qty)
return [
esc(row.Code),
esc(row.Name),
number(num(result.EscApplied) + num(result.EscExcluded)),
...QTY.map((k) => EstViews.cell("EstMainDat", index, k, row[k] ?? 0)),
number(result.EscExcluded),
number(result.EscApplied),
];
return [
esc(row.Code),
esc(row.Name),
EstViews.cell("EstMainDat", index, `A${comp}`, row[`A${comp}`]),
number(result[`Contract${comp}`]),
EstViews.cell("EstMainDat", index, comp, row[comp]),
number(result[`D${comp}`]),
number(result[`E${comp}`]),
number(result[`F${comp}`]),
EstViews.cell("EstMainDat", index, "EscPlanned", row.EscPlanned || 0),
EstViews.cell("EstMainDat", index, "EscActual", row.EscActual || 0),
EstViews.cell("EstMainDat", index, "EscProgress", row.EscProgress || 0),
number(result.EscExcluded),
number(result.EscApplied),
`<input type="checkbox" data-esc-auto="${index}" ${row.EscAuto !== false ? "checked" : ""} aria-label="적용제외 자동계산">`,
EstViews.cell("EstMainDat", index, "EscManualQuantity", row.EscManualQuantity ?? 0),
EstViews.cell("EstMainDat", index, "EscConstruction", row.EscConstruction ?? 0),
EstViews.cell("EstMainDat", index, "EscPS", row.EscPS ?? 0),
EstViews.cell("EstMainDat", index, "EscBefore", row.EscBefore ?? 0),
];
});
return box(
"물가변동 품목조정",
`<select id="escalation-component">${choices}</select>` +
button("capture-bid", "현재 단가를 입찰단가로 기록") +
button("load-bid", "입찰단가 불러오기") +
button("load-contract", "계약 내역 불러오기") +
button("load-progress-exclusion", "기성 누계수량 불러오기") +
button(
"applied-style-toggle",
qty ? "적용대가 보기 변경(풀기)" : "적용대가 보기 변경",
qty ? 'aria-pressed="true"' : "",
) +
button("applied-gn-load", "수량불러오기") +
button("es-wonga", "적용대가집계표") +
button("reset-escalation", "물가변동 입력 초기화") +
button("capture-baseline", "계약 내역 확정") +
button("escalation-prev", "이전", state.escalationPage === 0 ? "disabled" : "") +
`<span>${state.escalationPage + 1} / ${page.pages}쪽 · ${page.total}행</span>` +
button(
"escalation-next",
"다음",
state.escalationPage + 1 >= page.pages ? "disabled" : "",
) +
`<label>조사 도급액(0=단가로 계산) <input type="number" step="any" data-option="EscDesignTotal" value="${project.options.EscDesignTotal ?? 0}"></label>`,
table(
qty
? ["코드", "품명", "계약 수량", ...QTY_LABELS, "적용제외", "적용수량"]
: [
"코드",
"품명",
"입찰 당시",
"계약 단가",
"변동 당시",
"등락률 %",
"등락폭",
"조정단가",
"예정공정수량",
"실행공정수량",
"기성수량",
"적용제외",
"적용수량",
"제외 자동",
"수동 제외수량",
"시공중 수량(참고)",
"PS 수량(참고)",
"선시공 수량(참고)",
],
rows,
),
`조사 도급액 ${number(output.designTotal)} · 계약 도급액 ${number(output.contractTotal)} · 조정 도급액 ${number(output.adjustedTotal)} · 적용제외(간접비 포함) ${number(output.excludedTotal)} · 적용대가 ${number(output.applied)} · 조정률 ${number(output.rate)}% · 조정금액 ${number(output.adjustment)}. 계약·입찰 파일은 동일 행 순서로 불러옵니다. ${esc(output.errors.join(" / "))}`,
);
}
// 수량불러오기 한 벌 적용: kind 0 = 계약 수량(계약 내역 확정본, 없으면 당초 칸 GN_), 1~6 = 구분 칸
function load(project, kind, source) {
const rows = project.tables.EstMainDat,
from = source.tables?.EstMainDat || [],
base = project.baseline?.tables?.EstMainDat;
let count = 0;
rows.forEach((row, i) => {
const src = from[i];
if (!src) return;
if (kind === 0) {
if (base?.[i]) base[i].GN = num(src.GN);
else row.GN_ = num(src.GN);
} else row[QTY[kind - 1]] = num(src.GN);
count++;
});
const wonga = project.tables.Wonga || [],
fromWonga = source.tables?.Wonga || [];
wonga.forEach((row, i) => fromWonga[i] && (row[COST[kind]] = num(fromWonga[i].Cost)));
return count;
}
function summary(project) {
const out = EstBusiness.escalation(project),
wonga = project.tables.Wonga || [];
return wonga.map((row, i) => {
const contract =
row.EsContractCost != null ? num(row.EsContractCost) : num(out.contractWonga?.[i]?.Cost),
except = num(out.excludedWonga?.[i]?.Cost);
return { row, contract, except, apply: contract - except };
});
}
function summaryDialog(state) {
const { esc, number } = EstApp,
list = summary(state.project);
if (!list.length) throw Error("원가계산서가 없습니다.");
EstApp.showDialog(
"적용대가집계표",
`<div style="max-height:480px;overflow:auto"><table class="sheet"><thead><tr><th>비목</th><th>약어</th><th>계약금액</th><th>적용제외금액</th><th>적용대가금액</th><th>산출근거</th></tr></thead><tbody>${list
.map(
({ row, contract, except, apply }) =>
`<tr><td style="text-align:left">${esc([row.Bimog1, row.Bimog2, row.Name].filter(Boolean).join(" "))}</td><td>${esc(row.SName ?? "")}</td><td>${number(contract)}</td><td>${number(except)}</td><td>${number(apply)}</td><td style="text-align:left">${esc(row.VSusic ?? "")}</td></tr>`,
)
.join("")}</tbody></table></div>`,
);
}
async function gnLoad(state) {
const kind = await new Promise((resolve) =>
EstActions.form(
"수량불러오기",
[
{
name: "kind",
label: "불러올 수량분",
value: "",
choices: [["", "선택"], ...KINDS.map((k, i) => [i, k])],
},
],
(values) => resolve(values.kind),
),
);
if (kind === "" || kind == null) throw Error("불러올 수량분을 선택하세요..");
const file = await new Promise((resolve) => {
const picker = document.createElement("input");
picker.type = "file";
picker.accept = ".estx,.json";
picker.addEventListener("change", () => resolve(picker.files[0]));
picker.click();
});
if (!file) return;
const name = KINDS[Number(kind)];
let source;
try {
source = /\.estx$/i.test(file.name)
? await EstApp.api(`/api/import?name=${encodeURIComponent(file.name)}`, {
method: "POST",
body: await file.arrayBuffer(),
})
: EstModel.legacy(JSON.parse(await file.text()));
} catch {
throw Error(`${name} 를 불러오지 못했습니다...`);
}
let count = 0;
EstApp.change((project) => (count = load(project, Number(kind), source)));
state.notice = `${name} ${count}행을 불러왔습니다.`;
EstApp.render();
}
function handle(name, state) {
if (name === "applied-style-toggle") {
state.escQtyView = !state.escQtyView;
EstApp.render();
} else if (name === "applied-gn-load")
gnLoad(state).catch((error) => {
state.notice = error.message;
EstApp.render();
});
else if (name === "es-wonga") summaryDialog(state);
else return false;
return true;
}
return { view, load, summary, handle, KINDS };
})();
+1 -75
View File
@@ -229,81 +229,7 @@ const EstExtra = (() => {
"전회는 직전 확정 회차 누계를 읽습니다. 금회·사정은 수량·금액·비율 중 하나를 입력해 계산합니다.",
);
} else if (view === "escalation") {
const output = EstBusiness.escalation(project);
state.business = output;
const comp = state.escalationComponent || "NU";
const choices = EstMath.units
.map(
(key, i) =>
`<option value="${key}" ${key === comp ? "selected" : ""}>${["노무", "재료", "경비", "실적"][i]}</option>`,
)
.join("");
const page = EstBlockActions.escalationPage(state, project.tables.EstMainDat);
const rows = page.rows.map(({ row, index }) => {
const result = output.rows[index];
return [
esc(row.Code),
esc(row.Name),
EstViews.cell("EstMainDat", index, `A${comp}`, row[`A${comp}`]),
number(result[`Contract${comp}`]),
EstViews.cell("EstMainDat", index, comp, row[comp]),
number(result[`D${comp}`]),
number(result[`E${comp}`]),
number(result[`F${comp}`]),
EstViews.cell("EstMainDat", index, "EscPlanned", row.EscPlanned || 0),
EstViews.cell("EstMainDat", index, "EscActual", row.EscActual || 0),
EstViews.cell("EstMainDat", index, "EscProgress", row.EscProgress || 0),
number(result.EscExcluded),
number(result.EscApplied),
`<input type="checkbox" data-esc-auto="${index}" ${row.EscAuto !== false ? "checked" : ""} aria-label="적용제외 자동계산">`,
EstViews.cell("EstMainDat", index, "EscManualQuantity", row.EscManualQuantity ?? 0),
EstViews.cell("EstMainDat", index, "EscConstruction", row.EscConstruction ?? 0),
EstViews.cell("EstMainDat", index, "EscPS", row.EscPS ?? 0),
EstViews.cell("EstMainDat", index, "EscBefore", row.EscBefore ?? 0),
];
});
html = box(
"물가변동 품목조정",
`<select id="escalation-component">${choices}</select>` +
button("capture-bid", "현재 단가를 입찰단가로 기록") +
button("load-bid", "입찰단가 불러오기") +
button("load-contract", "계약 내역 불러오기") +
button("load-progress-exclusion", "기성 누계수량 불러오기") +
button("reset-escalation", "물가변동 입력 초기화") +
button("capture-baseline", "계약 내역 확정") +
button("escalation-prev", "이전", state.escalationPage === 0 ? "disabled" : "") +
`<span>${state.escalationPage + 1} / ${page.pages}쪽 · ${page.total}행</span>` +
button(
"escalation-next",
"다음",
state.escalationPage + 1 >= page.pages ? "disabled" : "",
) +
`<label>조사 도급액(0=단가로 계산) <input type="number" step="any" data-option="EscDesignTotal" value="${project.options.EscDesignTotal ?? 0}"></label>`,
table(
[
"코드",
"품명",
"입찰 당시",
"계약 단가",
"변동 당시",
"등락률 %",
"등락폭",
"조정단가",
"예정공정수량",
"실행공정수량",
"기성수량",
"적용제외",
"적용수량",
"제외 자동",
"수동 제외수량",
"시공중 수량(참고)",
"PS 수량(참고)",
"선시공 수량(참고)",
],
rows,
),
`조사 도급액 ${number(output.designTotal)} · 계약 도급액 ${number(output.contractTotal)} · 조정 도급액 ${number(output.adjustedTotal)} · 적용제외(간접비 포함) ${number(output.excludedTotal)} · 적용대가 ${number(output.applied)} · 조정률 ${number(output.rate)}% · 조정금액 ${number(output.adjustment)}. 계약·입찰 파일은 동일 행 순서로 불러옵니다. ${esc(output.errors.join(" / "))}`,
);
html = EstAppliedCost.view(state, project, { box, button, table });
} else if (view === "annual") {
const output = EstBusiness.annual(project, false);
state.business = output;
+1
View File
@@ -104,6 +104,7 @@ const allowed = new Set([
"native-misc-tools.js",
"native-excel-to-est.js",
"native-other-load.js",
"native-applied-cost.js",
"native-actions-more.js",
"native-wonga-tools.js",
"native-estimate-print.js",