Merge remote-tracking branch 'origin/CODEX' into sub_laptop_8
This commit is contained in:
@@ -12,6 +12,7 @@ for (const file of [
|
||||
"native-quantity.cjs",
|
||||
"native-wonga.js",
|
||||
"native-engine.js",
|
||||
"native-row-match.js",
|
||||
"native-escalation.js",
|
||||
"native-progress.js",
|
||||
"native-business.js",
|
||||
@@ -29,6 +30,7 @@ for (const file of [
|
||||
"native-c3r.js",
|
||||
"native-exchange.js",
|
||||
"native-verify.js",
|
||||
"native-block-actions.js",
|
||||
"native-extra.js",
|
||||
"native-business-actions.js",
|
||||
"native-forms.js",
|
||||
|
||||
@@ -1,6 +1,132 @@
|
||||
from test_est_rea_parity import run
|
||||
|
||||
|
||||
def test_machine_restore_group_uses_saved_baseline_without_changing_other_groups():
|
||||
result = run(
|
||||
"math",
|
||||
"""(()=>{
|
||||
const p=EstNative.empty();
|
||||
p.tables.MachMainDat=[{PkID:1,Index:1,Code:'M1',Name:'장비1'},{PkID:2,Index:0,GN:2},
|
||||
{PkID:3,Index:1,Code:'M2',Name:'장비2'},{PkID:4,Index:0,GN:3}];
|
||||
p.tables.MachTitleDat=[{Code:'M1',Name:'장비1'},{Code:'M2',Name:'장비2'}];
|
||||
const q=EstModel.validate(JSON.parse(JSON.stringify(EstBusiness.baseline(p))));
|
||||
q.tables.MachMainDat[1].GN=9;q.tables.MachMainDat[3].GN=7;
|
||||
const before=JSON.stringify(q), rows=EstMachine.restoreGroup(q,'M1');
|
||||
const originalUntouched=JSON.stringify(q)===before;
|
||||
let missing=false,duplicate=false,collision=false;
|
||||
const legacy=structuredClone(q);delete legacy.baseline.tables.MachMainDat;
|
||||
try { EstMachine.restoreGroup(legacy,'M1'); } catch { missing=true; }
|
||||
const conflict=structuredClone(q);conflict.tables.MachMainDat[3].PkID=2;
|
||||
try { EstMachine.restoreGroup(conflict,'M1'); } catch { collision=true; }
|
||||
q.tables.MachMainDat.push({Index:1,Code:'M1'});
|
||||
try { EstMachine.restoreGroup(q,'M1'); } catch { duplicate=true; }
|
||||
return {restored:rows[1].GN,other:rows[3].GN,originalUntouched,
|
||||
baseline:q.baseline.tables.MachMainDat[1].GN,missing,duplicate,collision};
|
||||
})()""",
|
||||
)
|
||||
assert result == {
|
||||
"restored": 2,
|
||||
"other": 7,
|
||||
"originalUntouched": True,
|
||||
"baseline": 2,
|
||||
"missing": True,
|
||||
"duplicate": True,
|
||||
"collision": True,
|
||||
}
|
||||
|
||||
|
||||
def test_machine_change_includes_new_changed_and_deleted_titles():
|
||||
result = run(
|
||||
"math",
|
||||
"""(()=>{
|
||||
const p=EstNative.empty();
|
||||
p.tables.MachTitleDat=[{Code:'M1',Name:'장비1',TC:150},{Code:'M3',Name:'신규',TC:50}];
|
||||
p.baseline={tables:{EstMainDat:[],MachTitleDat:[
|
||||
{Code:'M1',Name:'장비1',TC:100},{Code:'M2',Name:'삭제',TC:200}]}};
|
||||
return EstMachine.changes(p).map(({Code,originalTC,TC,difference,status,index})=>
|
||||
({Code,originalTC,TC,difference,status,index}));
|
||||
})()""",
|
||||
)
|
||||
assert result == [
|
||||
{
|
||||
"Code": "M1",
|
||||
"originalTC": 100,
|
||||
"TC": 150,
|
||||
"difference": 50,
|
||||
"status": "변경",
|
||||
"index": 0,
|
||||
},
|
||||
{
|
||||
"Code": "M3",
|
||||
"originalTC": None,
|
||||
"TC": 50,
|
||||
"difference": None,
|
||||
"status": "신규",
|
||||
"index": 1,
|
||||
},
|
||||
{
|
||||
"Code": "M2",
|
||||
"originalTC": 200,
|
||||
"TC": 0,
|
||||
"difference": -200,
|
||||
"status": "삭제",
|
||||
"index": None,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_recover_deleted_machine_group_and_reject_id_collision():
|
||||
result = run(
|
||||
"math",
|
||||
"""(()=>{
|
||||
const p=EstNative.empty();
|
||||
p.tables.MachMainDat=[{PkID:1,Index:1,Code:'M1',Name:'first'},
|
||||
{PkID:2,Index:-2,GN:2,TU:100},{PkID:3,Index:1,Code:'M2',Name:'second'},
|
||||
{PkID:4,Index:-2,GN:3,TU:100}];
|
||||
p.tables.MachTitleDat=[{PkID:10,Code:'M1',Name:'first',TC:200},
|
||||
{PkID:11,Code:'M2',Name:'second',TC:300}];
|
||||
const q=EstBusiness.baseline(p);
|
||||
q.tables.MachMainDat=q.tables.MachMainDat.slice(2);
|
||||
q.tables.MachTitleDat=q.tables.MachTitleDat.slice(1);
|
||||
const before=JSON.stringify(q), restored=EstMachine.recoverGroup(q,'M1');
|
||||
const unchanged=JSON.stringify(q)===before;
|
||||
const collision=structuredClone(q);collision.tables.MachMainDat[1].PkID=2;
|
||||
let rejected=false;try { EstMachine.recoverGroup(collision,'M1'); } catch { rejected=true; }
|
||||
q.tables.MachMainDat=restored.rows;q.tables.MachTitleDat=restored.titles;
|
||||
return {codes:q.tables.MachTitleDat.map(x=>x.Code),quantity:q.tables.MachMainDat[1].GN,
|
||||
original:EstMachine.usage(q).find(x=>x.Code==='M1').originalPrice,unchanged,rejected};
|
||||
})()""",
|
||||
)
|
||||
assert result == {
|
||||
"codes": ["M1", "M2"],
|
||||
"quantity": 2,
|
||||
"original": 200,
|
||||
"unchanged": True,
|
||||
"rejected": True,
|
||||
}
|
||||
|
||||
|
||||
def test_recovered_machine_group_recalculates_linked_estimate_after_reopen():
|
||||
result = run(
|
||||
"math",
|
||||
"""(()=>{
|
||||
const p=EstNative.empty();p.options.SourceRefresh=true;
|
||||
p.tables.MachMainDat=[{PkID:1,Index:1,Code:'M1',Name:'machine'},
|
||||
{PkID:2,Index:-2,Name:'fuel',GN:2,TU:100}];
|
||||
p.tables.MachTitleDat=[{PkID:10,Code:'M1',Name:'machine'}];
|
||||
p.tables.EstMainDat=[{PkID:20,Code:'M1',Name:'machine',LinkIndex:105,GN:2}];
|
||||
const q=EstBusiness.baseline(EstNative.calculate(p));
|
||||
q.tables.MachMainDat=[];q.tables.MachTitleDat=[];
|
||||
const restored=EstMachine.recoverGroup(JSON.parse(JSON.stringify(q)),'M1');
|
||||
q.tables.MachMainDat=restored.rows;q.tables.MachTitleDat=restored.titles;
|
||||
const out=EstNative.calculate(EstModel.validate(JSON.parse(JSON.stringify(q))));
|
||||
return {machine:out.tables.MachTitleDat[0].TC, estimate:out.tables.EstMainDat[0].TC,
|
||||
errors:out.calculation.errors};
|
||||
})()""",
|
||||
)
|
||||
assert result == {"machine": 200, "estimate": 400, "errors": []}
|
||||
|
||||
|
||||
def test_basic_prices_changes_for_material_and_labor():
|
||||
result = run(
|
||||
"math",
|
||||
|
||||
@@ -665,3 +665,12 @@ def test_vat_calculation_exclude_include_and_unit_rounding_for_materials():
|
||||
# M03: 12345 / 1.1 = 11222.72... -> 십원 단위 사사오입(11220), 절상(11230)
|
||||
assert result["m3RoundCost"] == 11220
|
||||
assert result["m3UpCost"] == 11230
|
||||
|
||||
|
||||
def test_vat_round_up_handles_small_remainders_and_exact_boundaries():
|
||||
result = run(
|
||||
"math",
|
||||
"""[EstVat.convertPrice(100.1, 0, 1, 2),
|
||||
EstVat.convertPrice(100, 0, 1, 2)]""",
|
||||
)
|
||||
assert result == [120, 110]
|
||||
|
||||
@@ -1,6 +1,84 @@
|
||||
from test_est_rea_parity import run
|
||||
|
||||
|
||||
def test_change_comparison_matches_reordered_duplicate_codes():
|
||||
result = run(
|
||||
"math",
|
||||
"""(()=>{
|
||||
const p=EstNative.empty();p.options.SourceRefresh=false;
|
||||
p.tables.EstMainDat=[{Code:'A',Name:'first',GN:10,GU:80},
|
||||
{Code:'A',Name:'second',GN:20,GU:50}];
|
||||
const q=EstBusiness.baseline(EstNative.calculate(p));
|
||||
[q.tables.EstMainDat[0],q.tables.EstMainDat[1]]=[q.tables.EstMainDat[1],q.tables.EstMainDat[0]];
|
||||
q.tables.EstMainDat[0].GN=22;
|
||||
return EstBusiness.changes(JSON.parse(JSON.stringify(q))).map(({Name,originalGN,GN,status})=>({Name,originalGN,GN,status}));
|
||||
})()""",
|
||||
)
|
||||
assert result == [
|
||||
{"Name": "second", "originalGN": 20, "GN": 22, "status": "changed"},
|
||||
{"Name": "first", "originalGN": 10, "GN": 10, "status": "unchanged"},
|
||||
]
|
||||
|
||||
|
||||
def test_change_comparison_tracks_duplicate_code_additions_and_deletions():
|
||||
result = run(
|
||||
"math",
|
||||
"""(()=>{
|
||||
const p=EstNative.empty();p.options.SourceRefresh=false;
|
||||
p.tables.EstMainDat=[{Code:'A',Name:'first',GN:10,GU:80},
|
||||
{Code:'A',Name:'second',GN:20,GU:50}];
|
||||
const q=EstBusiness.baseline(EstNative.calculate(p));
|
||||
q.tables.EstMainDat=[q.tables.EstMainDat[1],{Code:'A',Name:'new',GN:3,GU:10},
|
||||
{Code:'A',Name:'another',GN:4,GU:10}];
|
||||
return EstBusiness.changes(q).map(({Name,originalGN,status})=>({Name,originalGN,status}));
|
||||
})()""",
|
||||
)
|
||||
assert result == [
|
||||
{"Name": "second", "originalGN": 20, "status": "unchanged"},
|
||||
{"Name": "new", "originalGN": 0, "status": "new"},
|
||||
{"Name": "another", "originalGN": 0, "status": "new"},
|
||||
{"Name": "first", "originalGN": 10, "status": "deleted"},
|
||||
]
|
||||
|
||||
|
||||
def test_change_comparison_matches_duplicate_when_other_row_is_renamed():
|
||||
result = run(
|
||||
"math",
|
||||
"""(()=>{
|
||||
const p=EstNative.empty();p.options.SourceRefresh=false;
|
||||
p.tables.EstMainDat=[{Code:'A',Name:'first',GN:10,GU:80},
|
||||
{Code:'A',Name:'second',GN:20,GU:50}];
|
||||
const q=EstBusiness.baseline(EstNative.calculate(p));
|
||||
[q.tables.EstMainDat[0],q.tables.EstMainDat[1]]=[q.tables.EstMainDat[1],q.tables.EstMainDat[0]];
|
||||
q.tables.EstMainDat[1].Name='renamed';
|
||||
return EstBusiness.changes(q).map(({Name,originalGN,status})=>({Name,originalGN,status}));
|
||||
})()""",
|
||||
)
|
||||
assert result == [
|
||||
{"Name": "second", "originalGN": 20, "status": "unchanged"},
|
||||
{"Name": "renamed", "originalGN": 10, "status": "changed"},
|
||||
]
|
||||
|
||||
|
||||
def test_change_costs_match_reordered_duplicate_codes():
|
||||
result = run(
|
||||
"math",
|
||||
"""(()=>{
|
||||
const p=EstNative.empty();p.tables.Wonga=[{Gubun:'A',Name:'second',Cost:70},
|
||||
{Gubun:'A',Name:'new',Cost:5},{Gubun:'A',Name:'other',Cost:7}];
|
||||
p.baseline={name:'old',tables:{EstMainDat:[],Wonga:[
|
||||
{Gubun:'A',Name:'first',Cost:100},{Gubun:'A',Name:'second',Cost:60}]}};
|
||||
return EstBusiness.costChanges(p).map(({Name,originalCost,changedCost})=>({Name,originalCost,changedCost}));
|
||||
})()""",
|
||||
)
|
||||
assert result == [
|
||||
{"Name": "second", "originalCost": 60, "changedCost": 70},
|
||||
{"Name": "new", "originalCost": 0, "changedCost": 5},
|
||||
{"Name": "other", "originalCost": 0, "changedCost": 7},
|
||||
{"Name": "first", "originalCost": 100, "changedCost": 0},
|
||||
]
|
||||
|
||||
|
||||
def test_estimate_move_preserves_row_identity_and_blocks_ambiguous_or_total_rows():
|
||||
result = run(
|
||||
"math",
|
||||
@@ -712,4 +790,3 @@ def test_bid_rates_consecutive_apply_source_refresh_and_roundtrip_restore():
|
||||
assert result["manualBidRow"]["GU"] == 105
|
||||
assert result["manualResRow"]["NU"] == 100
|
||||
assert result["manualResRow"]["GU"] == 150
|
||||
|
||||
|
||||
@@ -20,6 +20,25 @@ def test_escalation_distinguishes_repeated_codes_without_row_ids():
|
||||
assert result["excludedTotal"] == 410
|
||||
|
||||
|
||||
def test_escalation_matches_reordered_duplicate_codes_to_original_rows():
|
||||
result = run(
|
||||
"math",
|
||||
"""(()=>{
|
||||
const p=EstNative.empty();p.options.SourceRefresh=false;p.options.EscDesignTotal=1000;
|
||||
p.tables.EstMainDat=[{Code:'A',Name:'first',GN:10,GU:80},
|
||||
{Code:'A',Name:'second',GN:20,GU:50},{LinkIndex:54}];
|
||||
const q=EstBusiness.baseline(EstNative.calculate(p));
|
||||
[q.tables.EstMainDat[0],q.tables.EstMainDat[1]]=[q.tables.EstMainDat[1],q.tables.EstMainDat[0]];
|
||||
Object.assign(q.tables.EstMainDat[0],{AGU:100,GU:120,EscActual:5});
|
||||
Object.assign(q.tables.EstMainDat[1],{AGU:100,GU:120,EscActual:2});
|
||||
return EstEscalation.calculate(JSON.parse(JSON.stringify(q)));
|
||||
})()""",
|
||||
)
|
||||
assert [row["ContractGU"] for row in result["rows"][:2]] == [50, 80]
|
||||
assert [row["EscApplied"] for row in result["rows"][:2]] == [15, 8]
|
||||
assert result["excludedTotal"] == 410
|
||||
|
||||
|
||||
def test_progress_exclusions_preserve_new_rows_and_reject_ambiguous_keys():
|
||||
result = run(
|
||||
"math",
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
from test_est_row_actions import run_js
|
||||
|
||||
|
||||
def test_change_reset_restores_machine_details_and_preserves_legacy_machine_rows():
|
||||
result = run_js("""(() => {
|
||||
const p=EstNative.empty();
|
||||
p.tables.MachMainDat=[{PkID:1,Index:1,Code:'M1'}, {PkID:2,Index:-2,GN:2,GN_:123,TU:100}];
|
||||
const q=EstBusiness.baseline(p);
|
||||
q.tables.MachMainDat[1].GN=9;
|
||||
const state={project:q};
|
||||
globalThis.confirm=()=>true;globalThis.EstApp={change:fn=>fn()};
|
||||
EstBlockActions.resetChangeAction(state);
|
||||
const restored=q.tables.MachMainDat[1].GN, original=q.tables.MachMainDat[1].GN_;
|
||||
const legacy=EstBusiness.baseline(p);
|
||||
delete legacy.baseline.tables.MachMainDat;
|
||||
legacy.tables.MachMainDat[1].GN=7;legacy.tables.MachMainDat[1].GN_=123;
|
||||
EstBlockActions.resetChangeAction({project:legacy});
|
||||
return {restored, original, legacy:legacy.tables.MachMainDat[1].GN,
|
||||
legacyOriginal:legacy.tables.MachMainDat[1].GN_};
|
||||
})()""")
|
||||
assert result == {"restored": 2, "original": 123, "legacy": 7, "legacyOriginal": 123}
|
||||
@@ -0,0 +1,47 @@
|
||||
from test_est_row_actions import run_js
|
||||
|
||||
|
||||
def test_progress_page_preserves_original_indices_and_clamps():
|
||||
result = run_js("""(() => {
|
||||
const rows = Array.from({length:205}, (_, i) => ({Code:'R'+i, Level:0}));
|
||||
rows.splice(1, 0, {Code:'heading', Level:1});
|
||||
const state = {progressPage:1};
|
||||
const second = EstBlockActions.progressPage(state, rows);
|
||||
state.progressPage = 99;
|
||||
const last = EstBlockActions.progressPage(state, rows);
|
||||
return {secondCount:second.rows.length, secondIndex:second.rows[0].index,
|
||||
lastCount:last.rows.length, page:state.progressPage, pages:last.pages};
|
||||
})()""")
|
||||
assert result == {"secondCount": 100, "secondIndex": 101, "lastCount": 5, "page": 2, "pages": 3}
|
||||
|
||||
|
||||
def test_escalation_page_excludes_summary_and_preserves_original_indices():
|
||||
result = run_js("""(() => {
|
||||
const rows = Array.from({length:201}, (_, i) => ({Code:'R'+i, Level:0}));
|
||||
rows.splice(2, 0, {Name:'소계', LinkIndex:54});
|
||||
const state = {escalationPage:1};
|
||||
const page = EstBlockActions.escalationPage(state, rows);
|
||||
return {count:page.rows.length, index:page.rows[0].index, total:page.total, pages:page.pages};
|
||||
})()""")
|
||||
assert result == {"count": 100, "index": 101, "total": 201, "pages": 3}
|
||||
|
||||
|
||||
def test_change_page_includes_deleted_and_new_rows():
|
||||
result = run_js("""(() => {
|
||||
const rows = Array.from({length:200}, (_, i) => ({Code:'R'+i, status:'unchanged'}));
|
||||
rows.push({Code:'old',status:'deleted'}, {Code:'new',status:'new'});
|
||||
const state = {changePage:2};
|
||||
const page = EstBlockActions.changePage(state, rows);
|
||||
return {statuses:page.rows.map(x=>x.row.status), total:page.total, pages:page.pages};
|
||||
})()""")
|
||||
assert result == {"statuses": ["deleted", "new"], "total": 202, "pages": 3}
|
||||
|
||||
|
||||
def test_business_paging_filters_without_changing_original_indices():
|
||||
result = run_js("""(() => {
|
||||
const rows = [{Code:'A',Name:'첫 항목'}, {Code:'B',Name:'둘째 항목',Size:'특수 규격'}];
|
||||
const state = {query:'특수', progressPage:2};
|
||||
const page = EstBlockActions.progressPage(state, rows);
|
||||
return {total:page.total, index:page.rows[0].index, page:state.progressPage};
|
||||
})()""")
|
||||
assert result == {"total": 1, "index": 1, "page": 0}
|
||||
@@ -27,6 +27,25 @@ def test_progress_cannot_lock_when_previous_result_is_missing():
|
||||
assert result["rejected"]
|
||||
|
||||
|
||||
def test_progress_cannot_lock_when_previous_costs_are_missing():
|
||||
result = run(
|
||||
"math",
|
||||
"""(()=>{
|
||||
const p=EstNative.empty();
|
||||
p.tables.EstMainDat=[{PkID:1,Code:'R',GN:10,GU:100},{LinkIndex:54}];
|
||||
p.tables.Wonga=[{Gubun:'D',BaseField:'TC'},{LinkIndex:55,VSusic:'D'}];
|
||||
p.progress=[{id:'one',locked:true,inputs:{},result:{rows:[]}},
|
||||
{id:'two',inputs:{'1':{Gum:2}}}];
|
||||
const calculated=EstProgress.calculate(p,'two');
|
||||
let rejected=false;
|
||||
try { EstProgress.lock(p,'two'); } catch { rejected=true; }
|
||||
return {errors:calculated.errors,rejected};
|
||||
})()""",
|
||||
)
|
||||
assert any("전회 기성 원가 결과가 없습니다" in error for error in result["errors"])
|
||||
assert result["rejected"]
|
||||
|
||||
|
||||
def test_progress_rejects_ambiguous_inputs_without_applying_them_to_multiple_rows():
|
||||
result = run(
|
||||
"math",
|
||||
|
||||
@@ -1,6 +1,44 @@
|
||||
from test_est_rea_parity import run
|
||||
|
||||
|
||||
def test_quantity_totals_handles_large_estimate_and_ambiguous_targets():
|
||||
result = run(
|
||||
"math",
|
||||
"""(()=>{
|
||||
const p=EstNative.empty();
|
||||
p.tables.EstMainDat=Array.from({length:5000},(_,i)=>({PkID:i+1,Code:'C'+i,Level:0}));
|
||||
p.quantities=Array.from({length:5000},(_,i)=>({Code:'Q'+i,formula:'1',TargetKey:'id:'+(i+1)}));
|
||||
const totals=EstQuantity.totals(p,'manual');
|
||||
p.tables.EstMainDat.push({PkID:1,Code:'other',Level:0});
|
||||
let duplicate=false;
|
||||
try { EstQuantity.totals(p,'manual'); } catch(e) { duplicate=e.message.includes('중복'); }
|
||||
return {count:totals.size,first:totals.get(0),last:totals.get(4999),duplicate};
|
||||
})()""",
|
||||
)
|
||||
assert result == {"count": 5000, "first": 1, "last": 1, "duplicate": True}
|
||||
|
||||
|
||||
def test_quantity_target_options_are_loaded_when_focused():
|
||||
result = run(
|
||||
"math",
|
||||
"""(()=>{
|
||||
const p=EstNative.empty();
|
||||
p.tables.EstMainDat=[{PkID:1,Code:'A',Name:'첫 행'},
|
||||
{PkID:2,Code:'B',Name:'둘째 행'},{PkID:3,Code:'C',Level:1}];
|
||||
globalThis.EstApp={esc:v=>String(v),state:{project:p}};
|
||||
const handlers={}; globalThis.document={addEventListener:(name,fn)=>handlers[name]=fn};
|
||||
const html=EstQuantity.targetSelect(p,{TargetKey:'id:2',TargetCode:'B'},'manual',0);
|
||||
EstQuantity.install();
|
||||
const select={dataset:{quantitySelect:'manual'},value:'id:2',innerHTML:''};
|
||||
handlers.focusin({target:select});
|
||||
return {initialOptions:(html.match(/<option/g)||[]).length,
|
||||
loadedOptions:(select.innerHTML.match(/<option/g)||[]).length,
|
||||
selected:select.value,loaded:select.dataset.quantityOptionsLoaded};
|
||||
})()""",
|
||||
)
|
||||
assert result == {"initialOptions": 2, "loadedOptions": 3, "selected": "id:2", "loaded": "1"}
|
||||
|
||||
|
||||
def test_excel_quantity_cell_rejects_invalid_address_or_non_numeric_value():
|
||||
result = run(
|
||||
"math",
|
||||
|
||||
@@ -37,6 +37,7 @@ def run_js(code: str) -> dict:
|
||||
'tools/est-test/native-machine.js',
|
||||
'tools/est-test/native-wonga.js',
|
||||
'tools/est-test/native-engine.js',
|
||||
'tools/est-test/native-row-match.js',
|
||||
'tools/est-test/native-rows.js',
|
||||
'tools/est-test/native-business.js',
|
||||
'tools/est-test/native-extra.js',
|
||||
@@ -1154,6 +1155,25 @@ def test_reset_wonga_action():
|
||||
assert result["handledTotal"] == 1270500
|
||||
|
||||
|
||||
def test_reset_wonga_error_preserves_rows_and_options():
|
||||
result = run_js("""(() => {
|
||||
const p = EstNative.empty();
|
||||
p.options.TotalFix = true;
|
||||
p.tables.Wonga = [
|
||||
{Gubun:'A', Name:'A', VSusic:'1', VSusic_:'[A]', Cost:10, Cost_:20}
|
||||
];
|
||||
const state = {project:p, view:'Wonga', selection:new Set()};
|
||||
globalThis.confirm = () => true;
|
||||
globalThis.EstApp = {change(fn) { fn(); }};
|
||||
const before = JSON.stringify(p);
|
||||
let rejected = false;
|
||||
try { EstBlockActions.resetWongaAction(state); } catch { rejected = true; }
|
||||
return {rejected, unchanged: JSON.stringify(p) === before};
|
||||
})()""")
|
||||
assert result["rejected"]
|
||||
assert result["unchanged"]
|
||||
|
||||
|
||||
def test_reset_progress_inputs_action():
|
||||
code = """(() => {
|
||||
const p = EstNative.empty();
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def test_watcher_detects_new_sources_but_ignores_data_directory(tmp_path):
|
||||
shutil.copy(ROOT / "tools/est-test/watch-server.cjs", tmp_path / "watch-server.cjs")
|
||||
(tmp_path / "server.cjs").write_text(
|
||||
"require('node:fs').appendFileSync('starts.txt', process.pid + String.fromCharCode(10));\n"
|
||||
"setInterval(() => {}, 1000);\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
data = tmp_path / "data"
|
||||
data.mkdir()
|
||||
process = subprocess.Popen(
|
||||
["node", "watch-server.cjs"],
|
||||
cwd=tmp_path,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=os.name != "nt",
|
||||
)
|
||||
|
||||
def starts():
|
||||
file = tmp_path / "starts.txt"
|
||||
return file.read_text(encoding="utf-8").splitlines() if file.exists() else []
|
||||
|
||||
def wait_for(count):
|
||||
deadline = time.monotonic() + 8
|
||||
while len(starts()) < count and time.monotonic() < deadline:
|
||||
time.sleep(0.05)
|
||||
assert len(starts()) == count
|
||||
|
||||
try:
|
||||
wait_for(1)
|
||||
source = tmp_path / "new-feature.js"
|
||||
source.write_text("// first\n", encoding="utf-8")
|
||||
wait_for(2)
|
||||
source.write_text("// second\n", encoding="utf-8")
|
||||
wait_for(3)
|
||||
(data / "work.js").write_text("// saved data\n", encoding="utf-8")
|
||||
time.sleep(0.8)
|
||||
assert len(starts()) == 3
|
||||
assert len(set(starts())) == 3
|
||||
finally:
|
||||
if os.name == "nt":
|
||||
subprocess.run(
|
||||
["taskkill", "/PID", str(process.pid), "/T", "/F"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
else:
|
||||
os.killpg(process.pid, signal.SIGTERM)
|
||||
process.wait(timeout=5)
|
||||
@@ -423,6 +423,42 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD
|
||||
- 백그라운드 상태 감시 및 자동 알림: `tools/est-test/monitor.cjs`를 통해 15초 주기로 `orca.exe` CLI(`shell: false`, timeout 4~5s)를 호출하여 CODEX 워크트리의 안티그래비티 작업 상태와 EST 5175 서버 헬스를 조회하고, 상태 변경 시에만 `monitor.log`에 기록(최대 512KB 순환). 안티그래비티 완료/대기 전이 시 Codex Orchestration Run(`run_cdb1558b9280`)으로 단발성 자동 알림(`status`/`messageId=msg_2c7bca748f91`)이 실제 전달됨을 확인(중복 폭주 방지 플래그 적용, 민감 환경변수 미기록, 재시작/자동지시 없음).
|
||||
## Codex · EST 독립 테스트 앱 (2026-11-04)
|
||||
|
||||
- [x] 수량산출 항목을 대용량 내역에 반영할 때 반복 검색을 줄이고 기존 연결·중복 오류 판정을 유지한다.
|
||||
- 자체 검증: 5,000개 항목과 내역 연결 및 중복 식별자 거부 확인. 수량 기능 시험 13개 통과.
|
||||
- [x] 수량산출 화면에서 내역 선택 목록을 필요할 때 생성하여 대용량 화면의 과도한 옵션 생성을 줄인다.
|
||||
- 자체 검증: 수량 시험 14개 통과. 실제 앱의 Edge 화면에서 기존 연결 표시, 포커스 시 목록 생성 및 다른 내역 선택 반영을 확인했다. 1,000개 선택 항목은 최초 옵션 2,000개만 생성하고 선택 시 해당 목록을 연다.
|
||||
- [x] 여러 수량산출서를 한꺼번에 반영할 때 내역 검색 자료를 공유하고 합계와 오류 처리를 유지한다.
|
||||
- 자체 검증: 여러 수량산출서 합산, 오류 시 변경 방지 등 수량 시험 14개 통과.
|
||||
- [x] 기성 내역 화면을 페이지로 나누어 대용량 자료도 조작하고 전체 계산·출력을 유지한다.
|
||||
- 자체 검증: 기성 시험 12개 통과. 실제 Edge 화면에서 205행 중 100행씩 표시, 다음 쪽 101번 행 입력 및 전체 계산 결과 205행 유지를 확인했다.
|
||||
- [x] 물가변동 내역 화면을 페이지로 나누고 행별 입력과 전체 계산을 유지한다.
|
||||
- 자체 검증: 물가·페이지 시험 9개 통과. 실제 Edge 화면에서 205행 중 다음 쪽 101번 행 입력과 전체 계산 결과 보존을 확인했다.
|
||||
- [x] 설계변경 비교 화면을 페이지로 나누고 신규·삭제·변경 내역과 원가 비교를 유지한다.
|
||||
- 자체 검증: 설계변경·페이지 시험 18개 통과. 실제 Edge 화면에서 205행 비교와 다음 쪽 표시를 확인했다.
|
||||
- [x] 새 작업을 열면 업무 화면의 페이지 위치를 첫 페이지로 초기화한다.
|
||||
- 자체 검증: 실제 Edge에서 새 작업을 연 뒤 기성·물가변동·설계변경 페이지가 모두 첫 페이지로 돌아가는 것을 확인했다.
|
||||
- [x] 업무 화면에서 코드·품명 검색과 기성·물가변동의 규격·단위 검색을 지원하고 검색할 때 첫 페이지부터 표시한다.
|
||||
- 자체 검증: 업무별 검색과 원래 행 인덱스 유지, 검색어 변경 시 첫 페이지 및 전체 계산 유지 확인. 관련 시험 37개 통과, 실제 Edge 기성 화면에서 검색 및 입력 행 확인.
|
||||
- [x] 물가변동에서 중복 코드 행의 순서가 바뀌어도 식별 가능한 행은 당초 단가와 정확히 연결한다.
|
||||
- 자체 검증: 중복 코드의 행을 뒤바꾸고 JSON 저장·재열기 후 당초 단가, 적용 수량 및 제외금액 확인. 물가·업무 시험 27개 통과. 품명·규격·단위까지 중복된 행은 기존 순서 대응을 유지한다.
|
||||
- [x] 설계변경 비교에서도 중복 코드 행의 재정렬을 식별해 당초 행과 연결한다.
|
||||
- 자체 검증: 설계변경·물가변동 시험 24개 통과. 실제 Edge 앱에서 같은 코드의 행 순서 변경 후 각 당초 수량과 계약 단가를 확인했다. 품명·규격·단위가 모두 같은 경우는 기존 순서 대응을 유지한다.
|
||||
- [x] 실행 중 새로 추가한 소스 파일도 감시하여 서버에 자동 반영하고 작업 데이터 저장에는 재시작하지 않는다.
|
||||
- 자체 검증: 별도 임시 서버에서 신규 파일 생성·수정에 따른 재시작과 데이터 하위 폴더 변경 무시 확인. 실제 테스트 서버에서 신규 소스 변경 시 프로세스 갱신 및 HTTP 200 확인.
|
||||
- [x] 중복 코드 행의 추가·삭제가 있어도 구별 가능한 행을 당초 자료와 연결한다.
|
||||
- 자체 검증: 중복 코드의 일부 삭제와 신규 행 추가 후 당초 수량과 신규·삭제 분류를 확인했다. 설계변경·물가 시험 25개 통과. 식별 정보까지 같은 행은 기존 순서 기준이다.
|
||||
- [x] 설계변경 원가 비교에서 중복 비목 코드의 순서 변경과 추가·삭제를 구별한다.
|
||||
- 자체 검증: 중복 코드 원가의 재정렬·신규·삭제에 따른 당초·변경 금액을 검증했다. 설계변경·물가 시험 26개 통과.
|
||||
- [x] 당초 확정 시 기계경비 호표 구성을 보관하고 선택한 호표를 복원한다. 기존 당초 자료와 현재 기초단가 사용 방식은 보존한다.
|
||||
- 자체 검증: JSON 저장·재열기, 다른 호표와 당초 사본 보존, 구버전 당초 및 중복 코드 거부 확인. 실제 Edge 화면에서 호표 복원과 실행 취소, 자동계산 300→200 및 서버 재계산 결과 보존을 확인했고 기초단가·설계변경·행 조작 시험 39개 통과. 당초 단가 자체가 아니라 산식을 복원하며, 자동계산 시 현재 기초단가를 사용한다.
|
||||
- [x] 기계경비 설계변경에서 당초 대비 신규·변경·삭제 항목을 표시한다.
|
||||
- 자체 검증: 기계경비 신규·변경·삭제 금액 및 상태 시험 포함 기초단가 시험 7개 통과. 실제 Edge에서 신규·삭제 행 표시와 삭제된 호표 열기 버튼 미표시 확인.
|
||||
- [x] 삭제한 기계경비 호표를 당초 구성에서 되살리고 기존 호표의 식별자를 보존한다.
|
||||
- 자체 검증: 원래 순서와 식별자 보존, 충돌 거부, JSON 재열기 후 연결 내역 재계산 및 실제 Edge에서 삭제 복원·실행 취소 확인. 기계경비 관련 시험 5개 통과.
|
||||
- 추가 회귀: 기초단가·설계변경·물가변동·기성·수량·서버 감시 시험 65개 통과.
|
||||
- 구버전 당초 사본에 기계 호표가 없는 경우 현재 호표와 기존 당초 필드를 보존하며, 초기화 확인창에 사본에 없는 자료가 유지됨을 표시한다. 기계 당초·행 조작 시험 16개 통과.
|
||||
- 기계 호표 당초 사본에는 기존 `_` 필드도 보관하며 설계변경 초기화 시 원래 값을 유지한다. 기계 당초·기초단가 시험 10개 통과.
|
||||
|
||||
- [x] 기성 회차 초기화가 선택한 회차만 변경하도록 보호하고 오류 시 입력 보존을 확인한다.
|
||||
- 자체 검증: 존재하지 않는 회차를 선택했을 때 첫 회차를 초기화하지 않고 오류를 표시하며 기존 입력이 보존되는 회귀 시험 통과 (`test_reset_progress_inputs_action`, 1 passed).
|
||||
- [x] 설계변경 초기화 시 수량 연결 대상을 실제 수량 반영 규칙과 동일하게 검사한다.
|
||||
@@ -430,6 +466,12 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD
|
||||
- [x] 확정된 전회 기성 결과가 없을 때 금회 누계를 잘못 확정하지 않도록 보호한다.
|
||||
- 자체 검증: 전회 결과가 없는 확정 회차 다음 계산에 오류를 표시하고 확정을 거부한다. 기성·행 조작 시험 24개 통과.
|
||||
- 추가 회귀: 설계변경·기성·물가변동·연차 관련 시험 79개 통과(기성·수량·행 조작 35개, 업무·설계변경·물가변동·연차 44개).
|
||||
- [x] 전회 기성 원가 결과가 누락된 경우 전회 원가 누계가 0원으로 바뀌지 않도록 보호한다.
|
||||
- 자체 검증: 이전 회차의 원가 결과가 없을 때 계산 오류를 표시하고 확정을 거부한다. 기성 시험 11개 통과.
|
||||
- [x] 원가 초기화 계산 실패 시 수식·금액·고정 옵션을 보존한다.
|
||||
- 자체 검증: 순환 원가 수식에서 초기화가 실패해도 작업본과 고정 옵션이 변하지 않으며 정상 초기화도 통과한다 (`test_reset_wonga`, 2 passed).
|
||||
- [x] 자재 부가세 계산의 절상 선택이 단단위 미만 금액을 올바르게 올리는지 확인한다.
|
||||
- 자체 검증: 십원 미만 나머지가 작은 값과 정확한 십원 경계의 절상을 확인했다 (`test_vat`, 2 passed).
|
||||
- [x] 일위대가 상세 비목 지정 변경을 해당 행에만 반영 → 자동·수동 계산, 원천 불일치·다른 호표 보존 시험.
|
||||
- 자체 검증: 관련 시험 33개 통과. Tailscale HTTP Edge에서 자동·수동 계산, 다른 호표 보존, 원천 단가 불일치 시 복원 차단 및 화면 오류 0 확인.
|
||||
- [x] 원천 재조회가 꺼진 내역의 비목 지정 변경을 해당 행에만 반영 → 자동·수동 계산, 미연결·다른 행 보존 시험.
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
|
||||
내역·일위대가·수식 단가산출·기계경비·기초 단가·원가계산서를 원본 테이블 구조로 편집합니다. 수량 연결, 설계변경 당초 보존, 낙찰률, 기성 회차와 사정 누계, 품목조정, 연차 배분, 소요 자원 전개, 예정공정표 화면을 제공합니다. 실행 취소·다시 실행, 검색, 행 편집, JSON 저장·열기, CSV·Excel·조달청 XML·JDL 기본 표 입출력과 브라우저 인쇄를 사용할 수 있습니다.
|
||||
|
||||
설계변경·기성·물가변동 내역은 100행씩 표시하며 코드와 품명으로 검색할 수 있습니다. 기성·물가변동은 규격과 단위도 검색합니다. 페이지와 검색은 화면 표시만 바꾸며 전체 내역 계산에는 영향을 주지 않습니다.
|
||||
|
||||
새로 당초를 확정하면 기계경비 호표 구성도 보관합니다. 기계경비 사용 내역에서 선택한 호표의 당초 산식을 복원하거나 설계변경 화면에서 삭제된 호표를 되살릴 수 있습니다. 자동계산이 켜져 있으면 현재 기초단가로 다시 계산합니다. 이전에 확정한 자료에는 호표 원본이 없을 수 있습니다.
|
||||
|
||||
작업모드의 `자료 오류 검색`은 코드 중복, 연결 자료 누락, 연결된 품명·규격 차이와 계산 오류를 표시합니다. 항목의 `행 열기`로 해당 자료 위치로 이동할 수 있습니다. 품명·규격 차이는 사용자가 확인할 항목으로 표시합니다.
|
||||
|
||||
작업모드의 `표준시장단가 사용 내역`은 표준시장단가에 연결된 내역별 적용 단가와 현재 기초 단가, 차이와 연결 상태를 보여 줍니다. 원래 내역으로 이동하거나 해당 표를 인쇄·Excel로 출력할 수 있습니다.
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<script defer src="native-quantity.cjs"></script>
|
||||
<script defer src="native-wonga.js"></script>
|
||||
<script defer src="native-engine.js"></script>
|
||||
<script defer src="native-row-match.js"></script>
|
||||
<script defer src="native-escalation.js"></script>
|
||||
<script defer src="native-progress.js"></script>
|
||||
<script defer src="native-business.js"></script>
|
||||
@@ -175,6 +176,13 @@
|
||||
<main>
|
||||
<div id="messages" role="alert" hidden></div>
|
||||
<div class="work-title"><b id="view-title">내역서</b><span id="selected-info"></span></div>
|
||||
<input
|
||||
id="business-search"
|
||||
type="search"
|
||||
aria-label="업무 내역 검색"
|
||||
placeholder="품명·규격·코드 검색"
|
||||
hidden
|
||||
/>
|
||||
<div id="standard-tools" class="row-tools">
|
||||
<button id="add-group" data-action="add-group" hidden>묶음 추가</button
|
||||
><button id="haljung-calc-btn" data-action="haljung-calc" hidden>할증계산</button
|
||||
|
||||
@@ -217,6 +217,7 @@ const EstApp = (() => {
|
||||
state.selection.clear();
|
||||
state.group = "";
|
||||
state.page = 0;
|
||||
state.progressPage = state.escalationPage = state.changePage = 0;
|
||||
state.quantitySheet = state.project.quantitySheets?.[0]?.Code || "manual";
|
||||
state.roundId =
|
||||
state.project.progress.find((round) => round.id === state.roundId)?.id ||
|
||||
@@ -260,6 +261,10 @@ const EstApp = (() => {
|
||||
}
|
||||
function render() {
|
||||
if (!state.project) return;
|
||||
if (state.query !== state.previousBusinessQuery) {
|
||||
state.progressPage = state.escalationPage = state.changePage = 0;
|
||||
state.previousBusinessQuery = state.query;
|
||||
}
|
||||
const active = document.activeElement,
|
||||
scroll = byId("work-content")?.scrollLeft || 0;
|
||||
const focus = active?.dataset.table
|
||||
@@ -394,6 +399,10 @@ const EstApp = (() => {
|
||||
}
|
||||
if (!saved) saved = await loadFromStorage(LEGACY_CACHE);
|
||||
setProject(saved ? JSON.parse(saved) : await api("/api/sample"));
|
||||
byId("business-search").addEventListener("input", (event) => {
|
||||
state.query = event.target.value;
|
||||
render();
|
||||
});
|
||||
EstActions.install();
|
||||
EstQuantity.install();
|
||||
EstReview.install();
|
||||
|
||||
@@ -341,19 +341,20 @@ const EstBlockActions = (() => {
|
||||
}
|
||||
if (
|
||||
!confirm(
|
||||
"설계변경 작업을 초기화하고 당초 내역 상태로 되돌리시겠습니까?\n(입력된 변경 수량 및 단가가 모두 당초 상태로 환원됩니다)",
|
||||
"설계변경 작업을 초기화하고 당초 내역 상태로 되돌리시겠습니까?\n당초 사본에 포함되지 않은 기계·기초단가 자료는 현재 상태로 유지됩니다.",
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 설계변경 대상 7개 테이블 목록 (EstBusiness.baseline 스펙 일치)
|
||||
// 설계변경 당초 사본에 포함된 테이블만 복원한다.
|
||||
const changeTables = [
|
||||
"EstMainDat",
|
||||
"HeadMainDat",
|
||||
"HeadTitleDat",
|
||||
"CostMainDat",
|
||||
"CostTitleDat",
|
||||
"MachMainDat",
|
||||
"MachTitleDat",
|
||||
"Wonga",
|
||||
];
|
||||
@@ -381,7 +382,7 @@ const EstBlockActions = (() => {
|
||||
];
|
||||
|
||||
EstApp.change(() => {
|
||||
// 1) baseline.tables에 존재하는 7개 대상 테이블만 당초 상태로 완전 복원 (신규/삭제 행 및 수량/단가 완벽 복원)
|
||||
// 1) baseline.tables에 존재하는 대상 테이블만 복원한다.
|
||||
for (const tName of changeTables) {
|
||||
if (project.baseline.tables[tName]) {
|
||||
project.tables[tName] = structuredClone(project.baseline.tables[tName]);
|
||||
@@ -390,7 +391,8 @@ const EstBlockActions = (() => {
|
||||
|
||||
// 2) 대상 테이블의 행들에서만 설계변경 관련 _ 접미사 필드 제거 (다른 업무 원본 필드, _origLinkIndex, _prevSCheck 등 완벽 보존)
|
||||
for (const tName of changeTables) {
|
||||
const rows = project.tables[tName];
|
||||
const rows =
|
||||
tName !== "MachMainDat" && project.baseline.tables[tName] && project.tables[tName];
|
||||
if (!Array.isArray(rows)) continue;
|
||||
for (const row of rows) {
|
||||
row.NewItem = false;
|
||||
@@ -422,22 +424,24 @@ const EstBlockActions = (() => {
|
||||
return;
|
||||
}
|
||||
|
||||
const direct = state.project.calculation?.direct || {};
|
||||
const bases = {
|
||||
NC: Number(direct.NC) || 0,
|
||||
GC: Number(direct.GC) || 0,
|
||||
JC: Number(direct.JC) || 0,
|
||||
SC: Number(direct.SC) || 0,
|
||||
TC: Number(direct.TC) || 0,
|
||||
노무비: Number(direct.NC) || 0,
|
||||
재료비: Number(direct.GC) || 0,
|
||||
경비: Number(direct.JC) || 0,
|
||||
실적: Number(direct.SC) || 0,
|
||||
직접공사비: Number(direct.TC) || 0,
|
||||
};
|
||||
const restored = structuredClone(rows);
|
||||
EstWonga.resetWonga(restored, state.project.options, bases);
|
||||
EstApp.change(() => {
|
||||
const direct = state.project.calculation?.direct || {};
|
||||
const bases = {
|
||||
NC: Number(direct.NC) || 0,
|
||||
GC: Number(direct.GC) || 0,
|
||||
JC: Number(direct.JC) || 0,
|
||||
SC: Number(direct.SC) || 0,
|
||||
TC: Number(direct.TC) || 0,
|
||||
노무비: Number(direct.NC) || 0,
|
||||
재료비: Number(direct.GC) || 0,
|
||||
경비: Number(direct.JC) || 0,
|
||||
실적: Number(direct.SC) || 0,
|
||||
직접공사비: Number(direct.TC) || 0,
|
||||
};
|
||||
state.project.tables.Wonga = restored;
|
||||
state.project.options.TotalFix = false;
|
||||
EstWonga.resetWonga(rows, state.project.options, bases);
|
||||
});
|
||||
state.notice = "원가계산서 항목이 초기화되었습니다.";
|
||||
}
|
||||
@@ -549,8 +553,72 @@ const EstBlockActions = (() => {
|
||||
});
|
||||
state.notice = `${selected.length}개 행의 레벨이 일괄 ${delta > 0 ? "증가" : "감소"}되었습니다.`;
|
||||
}
|
||||
function pageRows(state, rows, key, eligible) {
|
||||
const query = String(state.query || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const filtered = rows
|
||||
.map((row, index) => ({ row, index }))
|
||||
.filter(
|
||||
({ row }) =>
|
||||
eligible(row) &&
|
||||
(!query ||
|
||||
["Code", "Name", "Size", "Unite"].some((field) =>
|
||||
String(row[field] ?? "")
|
||||
.toLowerCase()
|
||||
.includes(query),
|
||||
)),
|
||||
);
|
||||
const pages = Math.max(1, Math.ceil(filtered.length / 100));
|
||||
state[key] = Math.max(0, Math.min(Number(state[key]) || 0, pages - 1));
|
||||
return {
|
||||
rows: filtered.slice(state[key] * 100, state[key] * 100 + 100),
|
||||
pages,
|
||||
total: filtered.length,
|
||||
};
|
||||
}
|
||||
function progressPage(state, rows) {
|
||||
return pageRows(
|
||||
state,
|
||||
rows,
|
||||
"progressPage",
|
||||
(row) =>
|
||||
!EstMath.num(row.Level) &&
|
||||
(row.Code || row.Name || [54, 55, 57, 58, 62, 67].includes(Number(row.LinkIndex))),
|
||||
);
|
||||
}
|
||||
function escalationPage(state, rows) {
|
||||
return pageRows(
|
||||
state,
|
||||
rows,
|
||||
"escalationPage",
|
||||
(row) =>
|
||||
(row.Code || row.Name) &&
|
||||
!EstMath.num(row.Level) &&
|
||||
![54, 55, 58, 59].includes(Number(row.LinkIndex)),
|
||||
);
|
||||
}
|
||||
function changePage(state, rows) {
|
||||
return pageRows(state, rows, "changePage", (row) => row.Code || row.Name);
|
||||
}
|
||||
|
||||
function handle(name, state) {
|
||||
if (name === "progress-prev" || name === "progress-next") {
|
||||
state.progressPage = (Number(state.progressPage) || 0) + (name === "progress-next" ? 1 : -1);
|
||||
EstApp.render();
|
||||
return true;
|
||||
}
|
||||
if (name === "escalation-prev" || name === "escalation-next") {
|
||||
state.escalationPage =
|
||||
(Number(state.escalationPage) || 0) + (name === "escalation-next" ? 1 : -1);
|
||||
EstApp.render();
|
||||
return true;
|
||||
}
|
||||
if (name === "change-prev" || name === "change-next") {
|
||||
state.changePage = (Number(state.changePage) || 0) + (name === "change-next" ? 1 : -1);
|
||||
EstApp.render();
|
||||
return true;
|
||||
}
|
||||
if (name === "block-start") {
|
||||
setBlockStart(state);
|
||||
return true;
|
||||
@@ -624,6 +692,9 @@ const EstBlockActions = (() => {
|
||||
resetWongaAction,
|
||||
resetChangeAction,
|
||||
adjustLevels,
|
||||
progressPage,
|
||||
escalationPage,
|
||||
changePage,
|
||||
handle,
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -265,13 +265,15 @@ const EstBusiness = (() => {
|
||||
"HeadTitleDat",
|
||||
"CostMainDat",
|
||||
"CostTitleDat",
|
||||
"MachMainDat",
|
||||
"MachTitleDat",
|
||||
"Wonga",
|
||||
]) {
|
||||
tables[table] = (project.tables[table] || []).map((row) => {
|
||||
const values = Object.fromEntries(
|
||||
Object.entries(row).filter(([key]) => !key.endsWith("_")),
|
||||
);
|
||||
const values =
|
||||
table === "MachMainDat"
|
||||
? row
|
||||
: Object.fromEntries(Object.entries(row).filter(([key]) => !key.endsWith("_")));
|
||||
return copy(values);
|
||||
});
|
||||
}
|
||||
@@ -302,15 +304,16 @@ const EstBusiness = (() => {
|
||||
}
|
||||
function changes(project) {
|
||||
const old = project.baseline?.tables.EstMainDat || [];
|
||||
const ids = EstRowMatch.identities(old, project.tables.EstMainDat, rowKey);
|
||||
const groups = new Map();
|
||||
old.forEach((row, index) => {
|
||||
const key = rowKey(row, index),
|
||||
const key = ids.original[index],
|
||||
group = groups.get(key) || [];
|
||||
group.push({ row, index });
|
||||
groups.set(key, group);
|
||||
});
|
||||
const changes = project.tables.EstMainDat.map((row, index) => {
|
||||
const saved = groups.get(rowKey(row, index))?.shift()?.row;
|
||||
const saved = groups.get(ids.current[index])?.shift()?.row;
|
||||
const stored =
|
||||
!project.baseline && ["Code_", "Name_", "GN_", "TC_"].some((key) => row[key] != null)
|
||||
? Object.fromEntries(
|
||||
@@ -384,17 +387,12 @@ const EstBusiness = (() => {
|
||||
}));
|
||||
}
|
||||
const old = project.baseline.tables.Wonga || [];
|
||||
function keyed(rows) {
|
||||
const counts = new Map();
|
||||
return rows.map((row) => {
|
||||
const identity = JSON.stringify(row.Gubun ? ["code", row.Gubun] : ["name", row.Name || ""]);
|
||||
const occurrence = counts.get(identity) || 0;
|
||||
counts.set(identity, occurrence + 1);
|
||||
return { row, key: JSON.stringify([identity, occurrence]) };
|
||||
});
|
||||
}
|
||||
const previous = new Map(keyed(old).map(({ row, key }) => [key, row]));
|
||||
const rows = keyed(current).map(({ row, key }) => {
|
||||
const identify = (row) =>
|
||||
JSON.stringify(row.Gubun ? ["code", row.Gubun] : ["name", row.Name || ""]);
|
||||
const ids = EstRowMatch.identities(old, current, identify);
|
||||
const previous = new Map(old.map((row, index) => [ids.original[index], row]));
|
||||
const rows = current.map((row, index) => {
|
||||
const key = ids.current[index];
|
||||
const saved = previous.get(key);
|
||||
previous.delete(key);
|
||||
return {
|
||||
|
||||
@@ -16,17 +16,9 @@ const EstEscalation = (() => {
|
||||
if (row[field + "_"] != null) old[field] = row[field + "_"];
|
||||
return old;
|
||||
});
|
||||
const identities = (items) => {
|
||||
const counts = new Map();
|
||||
return items.map((row, index) => {
|
||||
const id = key(row, index),
|
||||
occurrence = counts.get(id) || 0;
|
||||
counts.set(id, occurrence + 1);
|
||||
return JSON.stringify([id, occurrence]);
|
||||
});
|
||||
};
|
||||
const originalKeys = identities(original),
|
||||
rowKeys = identities(rows);
|
||||
const identities = EstRowMatch.identities(original, rows, key),
|
||||
originalKeys = identities.original,
|
||||
rowKeys = identities.current;
|
||||
const contracts = new Map(original.map((row, index) => [originalKeys[index], row]));
|
||||
const design = structuredClone(original),
|
||||
excluded = structuredClone(original);
|
||||
|
||||
@@ -84,27 +84,29 @@ const EstExtra = (() => {
|
||||
);
|
||||
} else if (view === "change") {
|
||||
const changes = EstBusiness.changes(project);
|
||||
const page = EstBlockActions.changePage(state, changes);
|
||||
html = box(
|
||||
"설계변경",
|
||||
button("capture-baseline", "현재 내역을 당초로 확정") +
|
||||
button("load-contract", "당초·계약 내역 불러오기") +
|
||||
button("recalculate-original", "당초 단가 재계산") +
|
||||
button("bid-rates", "낙찰률 적용") +
|
||||
button("restore-bid", "당초단가(100%) 복원"),
|
||||
button("restore-bid", "당초단가(100%) 복원") +
|
||||
button("change-prev", "이전", state.changePage === 0 ? "disabled" : "") +
|
||||
`<span>${state.changePage + 1} / ${page.pages}쪽 · ${page.total}행</span>` +
|
||||
button("change-next", "다음", state.changePage + 1 >= page.pages ? "disabled" : ""),
|
||||
table(
|
||||
["구분", "코드", "품명", "당초 수량", "변경 수량", "당초 금액", "변경 금액", "증감"],
|
||||
changes
|
||||
.filter((row) => row.Code || row.Name)
|
||||
.map((row) => [
|
||||
esc({ new: "신규", deleted: "삭제", changed: "변경", unchanged: "동일" }[row.status]),
|
||||
esc(row.Code),
|
||||
esc(row.Name),
|
||||
number(row.originalGN),
|
||||
number(row.GN),
|
||||
number(row.originalTC),
|
||||
number(row.TC),
|
||||
number(row.difference),
|
||||
]),
|
||||
page.rows.map(({ row }) => [
|
||||
esc({ new: "신규", deleted: "삭제", changed: "변경", unchanged: "동일" }[row.status]),
|
||||
esc(row.Code),
|
||||
esc(row.Name),
|
||||
number(row.originalGN),
|
||||
number(row.GN),
|
||||
number(row.originalTC),
|
||||
number(row.TC),
|
||||
number(row.difference),
|
||||
]),
|
||||
) +
|
||||
table(
|
||||
["원가 구분", "비목", "당초 금액", "변경 금액", "증감"],
|
||||
@@ -150,6 +152,11 @@ const EstExtra = (() => {
|
||||
output = { rows: [], errors: [e.message] };
|
||||
}
|
||||
state.business = output;
|
||||
const page = EstBlockActions.progressPage(state, output.rows);
|
||||
tools +=
|
||||
button("progress-prev", "이전", state.progressPage === 0 ? "disabled" : "") +
|
||||
`<span>${state.progressPage + 1} / ${page.pages}쪽 · ${page.total}행</span>` +
|
||||
button("progress-next", "다음", state.progressPage + 1 >= page.pages ? "disabled" : "");
|
||||
body =
|
||||
table(
|
||||
[
|
||||
@@ -166,58 +173,49 @@ const EstExtra = (() => {
|
||||
"누계수량",
|
||||
"누계금액",
|
||||
],
|
||||
output.rows
|
||||
.map((row, index) => ({ row, index }))
|
||||
.filter(
|
||||
({ row }) =>
|
||||
!EstMath.num(row.Level) &&
|
||||
(row.Code ||
|
||||
row.Name ||
|
||||
[54, 55, 57, 58, 62, 67].includes(Number(row.LinkIndex))),
|
||||
)
|
||||
.map(({ row, index }) => {
|
||||
const editable = ![54, 55, 58, 59, 62, 67].includes(Number(row.LinkIndex));
|
||||
const key = EstBusiness.rowKey(row, index),
|
||||
input = round.inputs?.[key] || {};
|
||||
const edit = (prefix) =>
|
||||
`<input data-progress="${prefix}" data-key="${esc(key)}" type="number" step="any" value="${input[prefix] ?? row[prefix + "GN"] ?? 0}" ${round.locked || !editable || input[prefix + "Mode"] === "components" ? "disabled" : ""}>` +
|
||||
(input[prefix + "Mode"] === "components"
|
||||
? EstMath.components
|
||||
.map(
|
||||
(field, i) =>
|
||||
`<label>${["노무", "재료", "경비", "실적"][i]}<input data-progress="${prefix + field}" data-key="${esc(key)}" type="number" step="any" value="${input[prefix + field] ?? 0}" ${round.locked ? "disabled" : ""}></label>`,
|
||||
)
|
||||
.join("")
|
||||
: "");
|
||||
const method = (prefix) =>
|
||||
!editable
|
||||
? "자동계산"
|
||||
: `<select data-progress="${prefix}Mode" data-key="${esc(key)}" ${round.locked ? "disabled" : ""}>${[
|
||||
["quantity", "수량"],
|
||||
["amount", "금액"],
|
||||
["ratio", "비율 %"],
|
||||
["components", "비목 금액"],
|
||||
]
|
||||
.map(
|
||||
([v, label]) =>
|
||||
`<option value="${v}" ${v === (input[`${prefix}Mode`] || "quantity") ? "selected" : ""}>${label}</option>`,
|
||||
)
|
||||
.join("")}</select>`;
|
||||
return [
|
||||
esc(row.Code),
|
||||
esc(row.Name),
|
||||
number(row.GN),
|
||||
number(row.JunGN),
|
||||
edit("Gum"),
|
||||
method("Gum"),
|
||||
edit("Sa"),
|
||||
method("Sa"),
|
||||
number(row.GumTC),
|
||||
number(row.SaTC),
|
||||
number(row.TotalGN),
|
||||
number(row.TotalTC),
|
||||
];
|
||||
}),
|
||||
page.rows.map(({ row, index }) => {
|
||||
const editable = ![54, 55, 58, 59, 62, 67].includes(Number(row.LinkIndex));
|
||||
const key = EstBusiness.rowKey(row, index),
|
||||
input = round.inputs?.[key] || {};
|
||||
const edit = (prefix) =>
|
||||
`<input data-progress="${prefix}" data-key="${esc(key)}" type="number" step="any" value="${input[prefix] ?? row[prefix + "GN"] ?? 0}" ${round.locked || !editable || input[prefix + "Mode"] === "components" ? "disabled" : ""}>` +
|
||||
(input[prefix + "Mode"] === "components"
|
||||
? EstMath.components
|
||||
.map(
|
||||
(field, i) =>
|
||||
`<label>${["노무", "재료", "경비", "실적"][i]}<input data-progress="${prefix + field}" data-key="${esc(key)}" type="number" step="any" value="${input[prefix + field] ?? 0}" ${round.locked ? "disabled" : ""}></label>`,
|
||||
)
|
||||
.join("")
|
||||
: "");
|
||||
const method = (prefix) =>
|
||||
!editable
|
||||
? "자동계산"
|
||||
: `<select data-progress="${prefix}Mode" data-key="${esc(key)}" ${round.locked ? "disabled" : ""}>${[
|
||||
["quantity", "수량"],
|
||||
["amount", "금액"],
|
||||
["ratio", "비율 %"],
|
||||
["components", "비목 금액"],
|
||||
]
|
||||
.map(
|
||||
([v, label]) =>
|
||||
`<option value="${v}" ${v === (input[`${prefix}Mode`] || "quantity") ? "selected" : ""}>${label}</option>`,
|
||||
)
|
||||
.join("")}</select>`;
|
||||
return [
|
||||
esc(row.Code),
|
||||
esc(row.Name),
|
||||
number(row.GN),
|
||||
number(row.JunGN),
|
||||
edit("Gum"),
|
||||
method("Gum"),
|
||||
edit("Sa"),
|
||||
method("Sa"),
|
||||
number(row.GumTC),
|
||||
number(row.SaTC),
|
||||
number(row.TotalGN),
|
||||
number(row.TotalTC),
|
||||
];
|
||||
}),
|
||||
) +
|
||||
`<p class="business-note">${esc((output.errors || []).join(" / "))} 금회 도급액 ${number(output.total)} · 기성비율 ${number(output.ratio)}%</p>`;
|
||||
body += table(
|
||||
@@ -248,36 +246,30 @@ const EstExtra = (() => {
|
||||
`<option value="${key}" ${key === comp ? "selected" : ""}>${["노무", "재료", "경비", "실적"][i]}</option>`,
|
||||
)
|
||||
.join("");
|
||||
const rows = project.tables.EstMainDat.map((row, index) => ({ row, index }))
|
||||
.filter(
|
||||
({ row }) =>
|
||||
(row.Code || row.Name) &&
|
||||
!EstMath.num(row.Level) &&
|
||||
![54, 55, 58, 59].includes(Number(row.LinkIndex)),
|
||||
)
|
||||
.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),
|
||||
];
|
||||
});
|
||||
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>` +
|
||||
@@ -287,6 +279,13 @@ const EstExtra = (() => {
|
||||
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(
|
||||
[
|
||||
|
||||
@@ -135,12 +135,25 @@ const EstMachine = (() => {
|
||||
function usage(project) {
|
||||
const titles = project.tables?.MachTitleDat || [];
|
||||
const grps = EstNative.groups(project.tables?.MachMainDat || []);
|
||||
const original = EstNative.groups(project.baseline?.tables?.MachMainDat || []);
|
||||
const oldTitles = project.baseline?.tables?.MachTitleDat || [];
|
||||
const oldByCode = new Map();
|
||||
for (const row of oldTitles) {
|
||||
const rows = oldByCode.get(row.Code) || [];
|
||||
rows.push(row);
|
||||
oldByCode.set(row.Code, rows);
|
||||
}
|
||||
return titles.map((title, index) => {
|
||||
const code = title.Code || "";
|
||||
const group = grps.get(code);
|
||||
const appliedPrice = EstMath.num(title.TC);
|
||||
const hasOriginal = title.TC_ != null;
|
||||
const originalPrice = hasOriginal ? EstMath.num(title.TC_) : null;
|
||||
const saved = oldByCode.get(code)?.length === 1 ? oldByCode.get(code)[0] : null;
|
||||
const hasOriginal = Boolean(saved) || title.TC_ != null;
|
||||
const originalPrice = saved
|
||||
? EstMath.num(saved.TC)
|
||||
: hasOriginal
|
||||
? EstMath.num(title.TC_)
|
||||
: null;
|
||||
const difference = hasOriginal ? appliedPrice - originalPrice : null;
|
||||
return {
|
||||
index,
|
||||
@@ -157,6 +170,7 @@ const EstMachine = (() => {
|
||||
detailCount: group ? group.rows.length : 0,
|
||||
status: !hasOriginal ? "신규(당초없음)" : Math.abs(difference) > 1e-6 ? "당초차이" : "일치",
|
||||
canApply: false, // 당초 계약값은 비교용이며 현재 단가 덮어쓰기 적용을 지원하지 않음
|
||||
canRestore: Boolean(group && original.has(code)),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -164,14 +178,28 @@ const EstMachine = (() => {
|
||||
function changes(project) {
|
||||
const labels = { new: "신규", deleted: "삭제", changed: "변경", unchanged: "동일" };
|
||||
const titles = project.tables?.MachTitleDat || [];
|
||||
return titles.map((title, index) => {
|
||||
const old = project.baseline?.tables?.MachTitleDat || [];
|
||||
const oldGroups = EstNative.groups(project.baseline?.tables?.MachMainDat || []);
|
||||
const identities = EstRowMatch.identities(old, titles, (row) => String(row.Code || ""));
|
||||
const previous = new Map(old.map((row, index) => [identities.original[index], row]));
|
||||
const result = titles.map((title, index) => {
|
||||
const appliedPrice = EstMath.num(title.TC);
|
||||
const hasOriginal = title.TC_ != null;
|
||||
const originalPrice = hasOriginal ? EstMath.num(title.TC_) : null;
|
||||
const saved = previous.get(identities.current[index]);
|
||||
previous.delete(identities.current[index]);
|
||||
const hasOriginal = Boolean(saved) || title.TC_ != null;
|
||||
const originalPrice = saved
|
||||
? EstMath.num(saved.TC)
|
||||
: hasOriginal
|
||||
? EstMath.num(title.TC_)
|
||||
: null;
|
||||
const difference = hasOriginal ? appliedPrice - originalPrice : null;
|
||||
const statusKey = !hasOriginal
|
||||
? "new"
|
||||
: Math.abs(difference) > 1e-6
|
||||
: Math.abs(difference) > 1e-6 ||
|
||||
(saved &&
|
||||
["Name", "Size", "Unite"].some(
|
||||
(field) => (saved[field] || "") !== (title[field] || ""),
|
||||
))
|
||||
? "changed"
|
||||
: "unchanged";
|
||||
return {
|
||||
@@ -186,6 +214,20 @@ const EstMachine = (() => {
|
||||
status: labels[statusKey] || statusKey,
|
||||
};
|
||||
});
|
||||
for (const title of previous.values())
|
||||
result.push({
|
||||
index: null,
|
||||
Code: title.Code || "",
|
||||
Name: title.Name || "",
|
||||
Size: title.Size || "",
|
||||
Unite: title.Unite || "",
|
||||
originalTC: EstMath.num(title.TC),
|
||||
TC: 0,
|
||||
difference: -EstMath.num(title.TC),
|
||||
status: labels.deleted,
|
||||
canRecover: oldGroups.has(title.Code),
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function applyRowPrice() {
|
||||
@@ -200,6 +242,77 @@ const EstMachine = (() => {
|
||||
);
|
||||
}
|
||||
|
||||
function ensureRestoredIds(saved, existing) {
|
||||
const ids = saved.map((row) => row.PkID).filter((id) => id != null);
|
||||
const unique = new Set(ids);
|
||||
if (unique.size !== ids.length || existing.some((row) => unique.has(row.PkID)))
|
||||
throw Error("기존 호표의 식별자와 충돌하여 복원할 수 없습니다.");
|
||||
}
|
||||
|
||||
function restoreGroup(project, code) {
|
||||
const current = project.tables?.MachMainDat || [],
|
||||
baseline = project.baseline?.tables?.MachMainDat;
|
||||
if (!Array.isArray(baseline))
|
||||
throw Error("기계 호표의 당초 구성이 없습니다. 당초를 다시 확정하세요.");
|
||||
const count = (rows) => rows.filter((row) => Number(row.Index) > 0 && row.Code === code).length;
|
||||
if (!code || count(current) !== 1 || count(baseline) !== 1)
|
||||
throw Error("복원할 기계 호표가 없거나 코드가 중복됩니다.");
|
||||
const from = EstNative.groups(baseline).get(code),
|
||||
to = EstNative.groups(current).get(code);
|
||||
ensureRestoredIds(from.rows, [
|
||||
...current.slice(0, to.start),
|
||||
...current.slice(to.start + to.rows.length),
|
||||
]);
|
||||
return [
|
||||
...current.slice(0, to.start),
|
||||
...structuredClone(from.rows),
|
||||
...current.slice(to.start + to.rows.length),
|
||||
];
|
||||
}
|
||||
|
||||
function recoverGroup(project, code) {
|
||||
const baseline = project.baseline?.tables;
|
||||
const original = baseline?.MachMainDat,
|
||||
oldTitles = baseline?.MachTitleDat;
|
||||
const current = project.tables.MachMainDat,
|
||||
titles = project.tables.MachTitleDat;
|
||||
if (!Array.isArray(original) || !Array.isArray(oldTitles))
|
||||
throw Error("기계 호표의 당초 구성이 없습니다.");
|
||||
const originals = original.filter((row) => Number(row.Index) > 0 && row.Code === code);
|
||||
const savedTitles = oldTitles.filter((row) => row.Code === code);
|
||||
if (
|
||||
!code ||
|
||||
originals.length !== 1 ||
|
||||
savedTitles.length !== 1 ||
|
||||
current.some((row) => Number(row.Index) > 0 && row.Code === code) ||
|
||||
titles.some((row) => row.Code === code)
|
||||
)
|
||||
throw Error("복원할 호표가 없거나 코드가 중복됩니다.");
|
||||
const group = EstNative.groups(original).get(code).rows;
|
||||
ensureRestoredIds(group, current);
|
||||
ensureRestoredIds(savedTitles, titles);
|
||||
const position = (saved, existing, isHeader) => {
|
||||
const at = saved.findIndex((row) => isHeader(row) && row.Code === code);
|
||||
const positions = new Map();
|
||||
existing.forEach((row, index) => {
|
||||
if (isHeader(row) && !positions.has(row.Code)) positions.set(row.Code, index);
|
||||
});
|
||||
for (const row of saved.slice(at + 1))
|
||||
if (isHeader(row) && positions.has(row.Code)) return positions.get(row.Code);
|
||||
return existing.length;
|
||||
};
|
||||
const rowAt = position(original, current, (row) => Number(row.Index) > 0);
|
||||
const titleAt = position(oldTitles, titles, () => true);
|
||||
return {
|
||||
rows: [...current.slice(0, rowAt), ...structuredClone(group), ...current.slice(rowAt)],
|
||||
titles: [
|
||||
...titles.slice(0, titleAt),
|
||||
structuredClone(savedTitles[0]),
|
||||
...titles.slice(titleAt),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function render() {
|
||||
const { state, esc, number, byId } = EstApp,
|
||||
isChange = state.view === "machine-change",
|
||||
@@ -209,15 +322,46 @@ const EstMachine = (() => {
|
||||
value = (v) => (v == null ? "" : number(v));
|
||||
if (isChange) {
|
||||
byId("work-content").innerHTML =
|
||||
`<div class="business-tools"><b>기계경비 설계변경</b><span>${rows.length}건 · ${page + 1}/${Math.max(1, Math.ceil(rows.length / 100))}쪽</span><button data-machine-page="-1" ${page === 0 ? "disabled" : ""}>이전</button><button data-machine-page="1" ${(page + 1) * 100 >= rows.length ? "disabled" : ""}>다음</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>${visible.map((row) => `<tr><td>${esc(row.status)}</td><td>${esc(row.Code)}</td><td>${esc(row.Name)}</td><td>${esc(row.Size)}</td><td>${esc(row.Unite)}</td><td>${value(row.originalTC)}</td><td>${value(row.TC)}</td><td>${value(row.difference)}</td><td><button data-machine-group="${esc(row.Code)}">호표 열기</button></td></tr>`).join("")}</tbody></table>`;
|
||||
`<div class="business-tools"><b>기계경비 설계변경</b><span>${rows.length}건 · ${page + 1}/${Math.max(1, Math.ceil(rows.length / 100))}쪽</span><button data-machine-page="-1" ${page === 0 ? "disabled" : ""}>이전</button><button data-machine-page="1" ${(page + 1) * 100 >= rows.length ? "disabled" : ""}>다음</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>${visible.map((row) => `<tr><td>${esc(row.status)}</td><td>${esc(row.Code)}</td><td>${esc(row.Name)}</td><td>${esc(row.Size)}</td><td>${esc(row.Unite)}</td><td>${value(row.originalTC)}</td><td>${value(row.TC)}</td><td>${value(row.difference)}</td><td>${row.index == null ? (row.canRecover ? `<button data-machine-recover="${esc(row.Code)}">당초 호표 복원</button>` : "") : `<button data-machine-group="${esc(row.Code)}">호표 열기</button>`}</td></tr>`).join("")}</tbody></table>`;
|
||||
return;
|
||||
}
|
||||
byId("work-content").innerHTML =
|
||||
`<div class="business-tools"><b>기계경비 사용 내역</b><span>${rows.length}건 · ${page + 1}/${Math.max(1, Math.ceil(rows.length / 100))}쪽</span><button data-machine-page="-1" ${page === 0 ? "disabled" : ""}>이전</button><button data-machine-page="1" ${(page + 1) * 100 >= rows.length ? "disabled" : ""}>다음</button></div><p>기계경비 호표의 산출 단가와 당초 계약 단가를 비교합니다. (기계 호표 단가는 MachMainDat 산출 공식에 의해 결정되며 당초값 덮어쓰기는 지원하지 않습니다.)</p><table class="sheet"><thead><tr><th>코드</th><th>품명</th><th>규격</th><th>단위</th><th>적용 단가</th><th>당초 단가</th><th>차이</th><th>상태</th><th>노무비</th><th>재료비</th><th>경비</th><th>작업</th></tr></thead><tbody>${visible.map((row) => `<tr><td>${esc(row.Code)}</td><td>${esc(row.Name)}</td><td>${esc(row.Size)}</td><td>${esc(row.Unite)}</td><td>${value(row.appliedPrice)}</td><td>${value(row.originalPrice)}</td><td>${value(row.difference)}</td><td>${esc(row.status)}</td><td>${value(row.NC)}</td><td>${value(row.GC)}</td><td>${value(row.JC)}</td><td><button data-machine-group="${esc(row.Code)}">호표 열기</button></td></tr>`).join("")}</tbody></table>`;
|
||||
`<div class="business-tools"><b>기계경비 사용 내역</b><span>${rows.length}건 · ${page + 1}/${Math.max(1, Math.ceil(rows.length / 100))}쪽</span><button data-machine-page="-1" ${page === 0 ? "disabled" : ""}>이전</button><button data-machine-page="1" ${(page + 1) * 100 >= rows.length ? "disabled" : ""}>다음</button></div><p>자동계산이 켜져 있으면 당초 산식 복원 후 현재 기초단가로 다시 계산합니다.</p><table class="sheet"><thead><tr><th>코드</th><th>품명</th><th>규격</th><th>단위</th><th>적용 단가</th><th>당초 단가</th><th>차이</th><th>상태</th><th>노무비</th><th>재료비</th><th>경비</th><th>작업</th></tr></thead><tbody>${visible.map((row) => `<tr><td>${esc(row.Code)}</td><td>${esc(row.Name)}</td><td>${esc(row.Size)}</td><td>${esc(row.Unite)}</td><td>${value(row.appliedPrice)}</td><td>${value(row.originalPrice)}</td><td>${value(row.difference)}</td><td>${esc(row.status)}</td><td>${value(row.NC)}</td><td>${value(row.GC)}</td><td>${value(row.JC)}</td><td><button data-machine-group="${esc(row.Code)}">호표 열기</button>${row.canRestore ? ` <button data-machine-restore="${esc(row.Code)}">당초 산식 복원</button>` : ""}</td></tr>`).join("")}</tbody></table>`;
|
||||
}
|
||||
|
||||
function install() {
|
||||
document.addEventListener("click", (event) => {
|
||||
const recover = event.target.closest("[data-machine-recover]");
|
||||
if (recover) {
|
||||
try {
|
||||
const result = recoverGroup(EstApp.state.project, recover.dataset.machineRecover);
|
||||
if (confirm("삭제한 기계 호표를 당초 구성으로 복원하시겠습니까?"))
|
||||
EstApp.change(() => {
|
||||
EstApp.state.project.tables.MachMainDat = result.rows;
|
||||
EstApp.state.project.tables.MachTitleDat = result.titles;
|
||||
});
|
||||
} catch (error) {
|
||||
EstApp.state.notice = error.message;
|
||||
EstApp.render();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const restore = event.target.closest("[data-machine-restore]");
|
||||
if (restore) {
|
||||
try {
|
||||
const rows = restoreGroup(EstApp.state.project, restore.dataset.machineRestore);
|
||||
if (
|
||||
confirm(
|
||||
"당초 기계 호표 구성을 복원하시겠습니까? 자동계산이 켜져 있으면 현재 기초단가로 다시 계산합니다.",
|
||||
)
|
||||
)
|
||||
EstApp.change(() => (EstApp.state.project.tables.MachMainDat = rows));
|
||||
} catch (error) {
|
||||
EstApp.state.notice = error.message;
|
||||
EstApp.render();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const step = event.target.closest("[data-machine-page]");
|
||||
if (step) {
|
||||
page += Number(step.dataset.machinePage);
|
||||
@@ -232,5 +376,15 @@ const EstMachine = (() => {
|
||||
});
|
||||
}
|
||||
|
||||
return { calculate, usage, changes, applyRowPrice, applyAllPrices, render, install };
|
||||
return {
|
||||
calculate,
|
||||
usage,
|
||||
changes,
|
||||
applyRowPrice,
|
||||
applyAllPrices,
|
||||
restoreGroup,
|
||||
recoverGroup,
|
||||
render,
|
||||
install,
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -46,6 +46,13 @@ const EstProgress = (() => {
|
||||
oldRows = new Map((previous?.rows || []).map((row, index) => [key(row, index), row]));
|
||||
if (earlier.at(-1)?.locked && !Array.isArray(previous?.rows))
|
||||
errors.push("확정된 전회 기성 결과가 없습니다. 전회 결과를 확인하세요.");
|
||||
if (
|
||||
earlier.at(-1)?.locked &&
|
||||
source.wonga?.length &&
|
||||
!Array.isArray(previous?.costs) &&
|
||||
!Array.isArray(previous?.wonga)
|
||||
)
|
||||
errors.push("확정된 전회 기성 원가 결과가 없습니다. 전회 결과를 확인하세요.");
|
||||
const segments = { Gum: [], Sa: [] };
|
||||
const separated = round.separateComponents !== false;
|
||||
rows.forEach((row, index) => {
|
||||
|
||||
@@ -141,7 +141,22 @@ const EstQuantity = (() => {
|
||||
});
|
||||
return { rows, errors };
|
||||
}
|
||||
function totals(project, selected, allowEmpty = false) {
|
||||
function estimateTargets(project) {
|
||||
const byKey = new Map(),
|
||||
byCode = new Map();
|
||||
project.tables.EstMainDat.forEach((row, index) => {
|
||||
if (Number(row.Level)) return;
|
||||
const add = (map, key) => {
|
||||
const entries = map.get(key) || [];
|
||||
entries.push(index);
|
||||
map.set(key, entries);
|
||||
};
|
||||
if (row.PkID != null) add(byKey, `id:${row.PkID}`);
|
||||
add(byCode, row.Code);
|
||||
});
|
||||
return { byKey, byCode };
|
||||
}
|
||||
function totals(project, selected, allowEmpty = false, targets = estimateTargets(project)) {
|
||||
let items;
|
||||
if (selected && selected !== "manual") {
|
||||
const sheet = project.quantitySheets?.find((s) => s.Code === selected);
|
||||
@@ -154,20 +169,15 @@ const EstQuantity = (() => {
|
||||
if (output.errors.length) throw Error(output.errors.join(" / "));
|
||||
items = output.rows;
|
||||
}
|
||||
const { byKey, byCode } = targets;
|
||||
const quantities = new Map();
|
||||
for (const item of items) {
|
||||
const code = item.TargetCode ?? item.LinkCode;
|
||||
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),
|
||||
);
|
||||
const targets = (item.TargetKey ? byKey.get(item.TargetKey) : byCode.get(code)) || [];
|
||||
if (!targets.length) throw Error(`연결 내역 없음: ${item.TargetKey || code}`);
|
||||
if (targets.length > 1) throw Error(`연결 내역 중복: ${item.TargetKey || code}`);
|
||||
const index = targets[0].index;
|
||||
const index = targets[0];
|
||||
quantities.set(index, (quantities.get(index) || 0) + Number(item.GN || 0));
|
||||
}
|
||||
if (!quantities.size && !allowEmpty) throw Error("연결할 내역코드를 먼저 입력하세요.");
|
||||
@@ -181,8 +191,9 @@ const EstQuantity = (() => {
|
||||
new Set(codes).size !== codes.length
|
||||
)
|
||||
throw Error("수량산출서 코드가 없거나 중복됩니다.");
|
||||
const targets = estimateTargets(project);
|
||||
for (const selected of ["manual", ...codes])
|
||||
for (const [index, value] of totals(project, selected, true))
|
||||
for (const [index, value] of totals(project, selected, true, targets))
|
||||
combined.set(index, (combined.get(index) || 0) + value);
|
||||
if (!combined.size) throw Error("연결할 내역코드를 먼저 입력하세요.");
|
||||
return combined;
|
||||
@@ -192,13 +203,10 @@ const EstQuantity = (() => {
|
||||
}
|
||||
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>`;
|
||||
const selected = item.TargetKey
|
||||
? `<option value="${esc(item.TargetKey)}" selected>${esc(item.TargetCode || "연결 내역")} · ${esc(item.TargetKey)}</option>`
|
||||
: "";
|
||||
return `<select data-quantity-select="${section}" data-index="${index}" aria-label="연결 내역 행"><option value="">내역 행 선택</option>${selected}</select>`;
|
||||
}
|
||||
function newCode(items) {
|
||||
const codes = new Set(items.map((row) => row.Code));
|
||||
@@ -310,6 +318,22 @@ const EstQuantity = (() => {
|
||||
`<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>`;
|
||||
}
|
||||
function install() {
|
||||
document.addEventListener("focusin", (event) => {
|
||||
const node = event.target;
|
||||
if (node.dataset.quantitySelect === undefined || node.dataset.quantityOptionsLoaded) return;
|
||||
const value = node.value;
|
||||
const { esc, state } = EstApp;
|
||||
node.innerHTML =
|
||||
'<option value="">내역 행 선택</option>' +
|
||||
state.project.tables.EstMainDat.filter((row) => row.PkID != null && !Number(row.Level))
|
||||
.map((row) => {
|
||||
const key = `id:${row.PkID}`;
|
||||
return `<option value="${esc(key)}">${esc(row.Name || "내역")} · ${esc(row.Code || "코드 없음")} · ${esc(row.PkID)}</option>`;
|
||||
})
|
||||
.join("");
|
||||
node.value = value;
|
||||
node.dataset.quantityOptionsLoaded = "1";
|
||||
});
|
||||
document.addEventListener("change", (event) => {
|
||||
const node = event.target,
|
||||
{ state } = EstApp;
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// Match repeated codes by their unique descriptions when both sets agree.
|
||||
const EstRowMatch = (() => {
|
||||
function identities(original, current, key) {
|
||||
const signature = (row) => JSON.stringify([row.Name || "", row.Size || "", row.Unite || ""]);
|
||||
const groups = (items) => {
|
||||
const result = new Map();
|
||||
items.forEach((row, index) => {
|
||||
const id = key(row, index),
|
||||
values = result.get(id) || [];
|
||||
values.push({ index, signature: signature(row) });
|
||||
result.set(id, values);
|
||||
});
|
||||
return result;
|
||||
};
|
||||
const oldGroups = groups(original),
|
||||
newGroups = groups(current),
|
||||
oldIds = [],
|
||||
currentIds = [];
|
||||
for (const id of new Set([...oldGroups.keys(), ...newGroups.keys()])) {
|
||||
const oldValues = oldGroups.get(id) || [],
|
||||
currentValues = newGroups.get(id) || [];
|
||||
if (oldValues.length <= 1 && currentValues.length <= 1) {
|
||||
const identity = JSON.stringify([id, "single"]);
|
||||
if (oldValues.length) oldIds[oldValues[0].index] = identity;
|
||||
if (currentValues.length) currentIds[currentValues[0].index] = identity;
|
||||
continue;
|
||||
}
|
||||
const counts = (values) => {
|
||||
const result = new Map();
|
||||
for (const value of values)
|
||||
result.set(value.signature, (result.get(value.signature) || 0) + 1);
|
||||
return result;
|
||||
};
|
||||
const oldCounts = counts(oldValues),
|
||||
currentCounts = counts(currentValues);
|
||||
const unique = (value) =>
|
||||
oldCounts.get(value.signature) === 1 && currentCounts.get(value.signature) === 1;
|
||||
for (const value of oldValues.filter(unique))
|
||||
oldIds[value.index] = JSON.stringify([id, "signature", value.signature]);
|
||||
for (const value of currentValues.filter(unique))
|
||||
currentIds[value.index] = JSON.stringify([id, "signature", value.signature]);
|
||||
const changedCount = oldValues.length !== currentValues.length;
|
||||
oldValues
|
||||
.filter((value) => !unique(value))
|
||||
.forEach((value, index) => {
|
||||
oldIds[value.index] = JSON.stringify([id, changedCount ? "old" : "remaining", index]);
|
||||
});
|
||||
currentValues
|
||||
.filter((value) => !unique(value))
|
||||
.forEach((value, index) => {
|
||||
currentIds[value.index] = JSON.stringify([id, changedCount ? "new" : "remaining", index]);
|
||||
});
|
||||
}
|
||||
return { original: oldIds, current: currentIds };
|
||||
}
|
||||
return { identities };
|
||||
})();
|
||||
@@ -1,7 +1,7 @@
|
||||
// REA TFRMCONVERTTOGANGUB (0xf616f0, 0xf61888)
|
||||
// 자재단가(StuffcDat) 부가세 계산: 포함(*1.1) / 제외(/1.1) 및 단단위 자릿수(사사오입/절하/절상) 처리
|
||||
const EstVat = (() => {
|
||||
const { num, fixed } = EstMath;
|
||||
const { num, fixed, even } = EstMath;
|
||||
|
||||
// REA radCalcMethod: 0 = 부가세포함(×1.1), 1 = 부가세제외(÷1.1)
|
||||
// REA radDigetIndex: 0 = 사사오입(1), 1 = 절하(2), 2 = 절상(3)
|
||||
@@ -16,6 +16,10 @@ const EstVat = (() => {
|
||||
// REA 관례: 0이면 원단위(0), n > 0이면 10^n 미만(-n) 또는 소수점
|
||||
// 일반적으로 단단위계산에서 spnDiget은 자리수를 나타냄. 0이면 정수 원단위(0), n이면 -n 단위 절하/사사오입/절상.
|
||||
const digits = -Math.max(0, Math.trunc(num(diget)));
|
||||
if (digetMethod === 2) {
|
||||
const step = 10 ** -digits;
|
||||
return Math.ceil(even(scaled * 10000) / 10000 / step) * step;
|
||||
}
|
||||
const m = methodMap[digetMethod] ?? 2;
|
||||
return fixed(scaled, digits, m);
|
||||
}
|
||||
|
||||
@@ -95,6 +95,8 @@ const EstViews = (() => {
|
||||
.join("") || '<p class="muted">자료가 없습니다. 묶음 추가를 누르세요.</p>';
|
||||
}
|
||||
byId("search").value = state.query;
|
||||
byId("business-search").value = state.query;
|
||||
byId("business-search").hidden = !["progress", "escalation", "change"].includes(view);
|
||||
byId("standard-tools").hidden = !project.tables[view];
|
||||
byId("add-group").hidden = !grouped;
|
||||
const haljungBtn = byId("haljung-calc-btn");
|
||||
|
||||
@@ -233,7 +233,8 @@ const EstWonga = (() => {
|
||||
}
|
||||
|
||||
// 1) 당초 보존값(VSusic_, Cost_, CalcIndex_)이 있는 경우 복원
|
||||
for (const r of rows) {
|
||||
const restored = structuredClone(rows);
|
||||
for (const r of restored) {
|
||||
if (r.VSusic_ !== undefined) {
|
||||
r.VSusic = r.VSusic_;
|
||||
}
|
||||
@@ -247,14 +248,14 @@ const EstWonga = (() => {
|
||||
|
||||
// 2) TotalFix 옵션 해제 및 일반 재계산 수행
|
||||
const newOptions = { ...options, TotalFix: false };
|
||||
const calcResult = calculate(rows, newOptions, bases);
|
||||
const calcResult = calculate(restored, newOptions, bases);
|
||||
if (calcResult.errors && calcResult.errors.length) {
|
||||
throw Error(`원가계산서 초기화 재계산 오류: ${calcResult.errors.join("; ")}`);
|
||||
}
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
if (calcResult.rows[i]) {
|
||||
rows[i].Cost = calcResult.rows[i].Cost;
|
||||
Object.assign(rows[i], calcResult.rows[i]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ const scripts = [
|
||||
"native-quantity.cjs",
|
||||
"native-wonga.js",
|
||||
"native-engine.js",
|
||||
"native-row-match.js",
|
||||
"native-escalation.js",
|
||||
"native-progress.js",
|
||||
"native-business.js",
|
||||
|
||||
@@ -1,43 +1,57 @@
|
||||
// 독립 EST 테스트 서버 감시/감독 프로세스
|
||||
// EST 서버 감시: 작업 데이터 디렉터리는 감시하지 않는다.
|
||||
const { spawn } = require("node:child_process");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const base = __dirname;
|
||||
const serverScript = path.join(base, "server.cjs");
|
||||
let child,
|
||||
timer,
|
||||
restarting = false,
|
||||
stopping = false;
|
||||
|
||||
// data 디렉터리, 로그 파일 등은 제외하고 소스코드 파일만 명시적으로 감시 대상에 포함
|
||||
const watchArgs = fs
|
||||
.readdirSync(base)
|
||||
.filter(
|
||||
(file) =>
|
||||
(file.endsWith(".js") ||
|
||||
file.endsWith(".cjs") ||
|
||||
file === "index.html" ||
|
||||
file === "native.css") &&
|
||||
!file.startsWith(".") &&
|
||||
file !== "watch-server.cjs",
|
||||
)
|
||||
.map((file) => `--watch-path=${path.join(base, file)}`);
|
||||
function start() {
|
||||
child = spawn(process.execPath, [path.join(base, "server.cjs")], {
|
||||
cwd: base,
|
||||
env: process.env,
|
||||
stdio: "inherit",
|
||||
});
|
||||
child.on("exit", (code, signal) => {
|
||||
if (stopping) return;
|
||||
if (restarting) {
|
||||
restarting = false;
|
||||
start();
|
||||
} else {
|
||||
console.error(`[supervisor] 서버 종료: ${code ?? signal}`);
|
||||
process.exit(code || 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const args = ["--watch", ...watchArgs, serverScript];
|
||||
|
||||
console.log("[supervisor] Starting server with watch paths (excluding data/)...");
|
||||
|
||||
const child = spawn(process.execPath, args, {
|
||||
cwd: base,
|
||||
env: process.env,
|
||||
stdio: "inherit",
|
||||
const watcher = fs.watch(base, (event, filename) => {
|
||||
const name = filename && filename.toString();
|
||||
if (!name || name === "watch-server.cjs" || !/\.(?:js|cjs|html|css)$/i.test(name)) return;
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
if (restarting || stopping) return;
|
||||
restarting = true;
|
||||
console.log(`[supervisor] 변경 감지: ${name}`);
|
||||
child.kill();
|
||||
}, 300);
|
||||
});
|
||||
watcher.on("error", (error) => {
|
||||
console.error(`[supervisor] 감시 오류: ${error.message}`);
|
||||
stopping = true;
|
||||
child.kill();
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
console.log(`[supervisor] Server exited with code ${code}, signal ${signal}`);
|
||||
process.exit(code || 0);
|
||||
});
|
||||
|
||||
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
||||
process.on(sig, () => {
|
||||
child.kill(sig);
|
||||
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
||||
process.on(signal, () => {
|
||||
stopping = true;
|
||||
clearTimeout(timer);
|
||||
watcher.close();
|
||||
child.kill();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
start();
|
||||
|
||||
Reference in New Issue
Block a user