feat(stmate-test): 2차 화면·코드관리 합침 · % 줄(조달수수료) 계산·저장 재계산 · 줄 고정 단가 보존 · 장부
- 코드 등록·삭제·변경·병합·데이타병합·일괄변경·검색·동일코드·풀어넣기 끝전 등(ui_code · ui_merge · api_ui_code) - 코드 붙은 % 줄 = 같은 상위 공종 앞 말단 줄 비목 합 × 율(server pct_row_base · calc.js) · 저장 때 다시 셈(stc_save _pct_rows) — 실무 7건 저장값 그대로 - 내역 줄 단가 ≠ 단가목록이면 줄 고정 단가(fixedUnit) - 메뉴 94개 눌러 오류 0 · 시험 502 통과 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,349 @@
|
||||
// STMATE 재현 앱 코드 관리(ui_code.js)·단가 일괄 손질(ui_code_amt.js) 순수 계산 — stdin = { load, mode }
|
||||
// mode "ops" = 신규등록·코드변경·+α·항목교체·삭제를 차례로 한 뒤 저장 요청(buildSavePayload + structPayload)과 사실값
|
||||
// mode "direct" = 불러온 자료 그대로 직공비(다시 불러온 작업본 대조용)
|
||||
// mode "amt" = 일괄변경·할증·초기화·손료계수·특수문자 수치
|
||||
globalThis.window = globalThis;
|
||||
const D = "../../tools/stmate-test/";
|
||||
[
|
||||
"kanjub_parts.js",
|
||||
"kanjub_calc.js",
|
||||
"calc.js",
|
||||
"smt_eval.js",
|
||||
"smt_engine.js",
|
||||
"view_prj_boq.js",
|
||||
].forEach((f) => require(D + f));
|
||||
const UC = require(D + "ui_code.js");
|
||||
const UA = require(D + "ui_code_amt.js");
|
||||
const UM = require(D + "ui_merge.js");
|
||||
const Calc = window.STMATE_CALC;
|
||||
|
||||
let buf = "";
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.on("data", (x) => (buf += x));
|
||||
process.stdin.on("end", () => {
|
||||
const { load, mode } = JSON.parse(buf);
|
||||
const fresh = () => JSON.parse(JSON.stringify(load));
|
||||
const direct = (d) => Calc.computeAll(d.sim_state).boqComputed.totals.directT;
|
||||
const txt = (v) => String(v == null ? "" : v).trim();
|
||||
if (mode === "direct") return process.stdout.write(JSON.stringify({ direct: direct(load) }));
|
||||
if (mode === "amt") return process.stdout.write(JSON.stringify(amt(load)));
|
||||
if (mode === "expand") return process.stdout.write(JSON.stringify(expand(load)));
|
||||
if (mode === "units") return process.stdout.write(JSON.stringify(units(load)));
|
||||
if (mode === "merge") return process.stdout.write(JSON.stringify(merge(JSON.parse(buf))));
|
||||
|
||||
const d = fresh();
|
||||
const env = { d, state: d.sim_state, memo: {} };
|
||||
const out = { t0: direct(d) };
|
||||
const boqCodes = new Set(d.boq_rows.map((r) => txt(r.BDCODE1)));
|
||||
const comps = (c) => d.bdqty_rows.filter((r) => txt(r.BDCODE) === c).length;
|
||||
|
||||
// 1. 신규등록(복사) — 내역에 쓰인 구성 있는 일위대가
|
||||
const bsrc = d.costn_rows
|
||||
.map(UC.codeOf)
|
||||
.find((c) => c[0] === "B" && boqCodes.has(c) && comps(c) > 0);
|
||||
const add = UC.addCode(env, { from: bsrc });
|
||||
out.add = {
|
||||
ok: add.ok,
|
||||
src: bsrc,
|
||||
code: add.code,
|
||||
comps: [comps(bsrc), comps(add.code)],
|
||||
t: direct(d),
|
||||
};
|
||||
|
||||
// 2. 코드변경 — 쓰이는 재료비 하나 + 식 있는 산근 하나를 빈 번호로
|
||||
const used = UC.usedSet(env);
|
||||
const all = new Set(d.costn_rows.map(UC.codeOf));
|
||||
const pickFree = (k) => UC.nextCode(d.costn_rows, k, all);
|
||||
const mOld = d.costn_rows.map(UC.codeOf).find((c) => c[0] === "M" && used.has(c));
|
||||
const dOld = d.costn_rows
|
||||
.map(UC.codeOf)
|
||||
.find(
|
||||
(c) =>
|
||||
c[0] === "D" &&
|
||||
UC.memoText(env, c) &&
|
||||
used.has(c) &&
|
||||
UC.memoRefs(UC.memoText(env, c)).length,
|
||||
);
|
||||
const map = { [mOld]: pickFree("M") };
|
||||
if (dOld) map[dOld] = pickFree("D");
|
||||
const refCount = (c) => ({
|
||||
boq: Object.values(d.boq_tables).reduce(
|
||||
(n, rows) => n + rows.filter((r) => txt(r.BDCODE1) === c).length,
|
||||
0,
|
||||
),
|
||||
comp: d.bdqty_rows.filter((r) => txt(r.CODE) === c).length,
|
||||
memo: d.costn_rows.filter((r) => UC.memoRefs(UC.memoText(env, UC.codeOf(r))).includes(c))
|
||||
.length,
|
||||
});
|
||||
const before = Object.fromEntries(Object.keys(map).map((c) => [c, refCount(c)]));
|
||||
const bad = UC.checkChange(d.costn_rows, { [mOld]: bsrc }).errs.length > 0; // 다른 종류·있는 코드 거부
|
||||
const ch = UC.changeCodes(env, map);
|
||||
out.change = {
|
||||
ok: ch.ok,
|
||||
map,
|
||||
before,
|
||||
after: Object.fromEntries(Object.entries(map).map(([a, b]) => [b, refCount(b)])),
|
||||
gone: Object.keys(map).map(refCount),
|
||||
bad,
|
||||
t: direct(d),
|
||||
};
|
||||
|
||||
// 3. 항목교체 — 원래 일위대가 → 복사본(값 같음) · 원래 것은 안 쓰이게 됨 → 지움
|
||||
const rp = UC.replaceCodes(env, { [bsrc]: add.code });
|
||||
const refused = UC.deleteCodes(env, [add.code]); // 쓰이는 코드
|
||||
const del = UC.deleteCodes(env, [bsrc]);
|
||||
out.replace = {
|
||||
ok: rp.ok,
|
||||
t: direct(d),
|
||||
refused: !refused.ok,
|
||||
del: del.ok && del.deleted[0] === bsrc,
|
||||
};
|
||||
out.final = Object.fromEntries(Object.values(map).map((c) => [c, refCount(c)])); // 모든 손질 뒤 참조 개수
|
||||
out.finalComps = comps(add.code);
|
||||
|
||||
// 4. +α — 견적·노무 아닌 종류 하나를 통째로 밀어 냄(겹침 없이)
|
||||
const d2 = fresh(),
|
||||
env2 = { d: d2, state: d2.sim_state, memo: {} };
|
||||
const t2 = direct(d2);
|
||||
const pm = UC.plusMap(d2.costn_rows, { X: 1000 });
|
||||
const pch = UC.changeCodes(env2, pm);
|
||||
out.plus = {
|
||||
n: Object.keys(pm).length,
|
||||
ok: pch.ok || !Object.keys(pm).length,
|
||||
same: direct(d2) === t2,
|
||||
};
|
||||
out.plus.compOk = d2.bdqty_rows.every((r) => !/^X/.test(txt(r.BDCODE)) || !pm[txt(r.BDCODE)]);
|
||||
|
||||
// 5. 동일항목 · 일괄삭제 대상
|
||||
out.same = UC.sameItems(d.costn_rows).length;
|
||||
out.unused = { minus: UC.unusedCodes(env, false).length, plus: UC.unusedCodes(env, true).length };
|
||||
|
||||
const ctx = {
|
||||
state: d.sim_state,
|
||||
dbfData: d,
|
||||
currentSourcePath: load.source_path,
|
||||
uiState: { editedSmtMap: env.memo },
|
||||
};
|
||||
ctx.computed = Calc.calculateAll(ctx.state);
|
||||
const p = window.STMATE_VIEW_PRJ_BOQ.buildSavePayload(ctx);
|
||||
out.payload = Object.assign(p, UC.structPayload(d));
|
||||
out.needs = UC.needsUiSave(d);
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
});
|
||||
|
||||
function amt(load) {
|
||||
const txt = (v) => String(v == null ? "" : v).trim();
|
||||
const num = (v) => Number(txt(v)) || 0;
|
||||
const rows = JSON.parse(JSON.stringify(load.costn_rows));
|
||||
const out = {};
|
||||
// 일괄변경 — 1차 × 110% → 2차(낙찰률 행 제외) · 다시 100% 로 1차 → 1차 무변화
|
||||
const nag = new Set(rows.filter((r) => num(r.NAGCD) > 0).map((r) => txt(r.CODE)));
|
||||
const ch = UA.chgAmt(rows, { kinds: "MLS", from: 1, to: 2, rate: 110, digits: 0, mode: "trunc" });
|
||||
const r0 = load.costn_rows.find((r) => txt(r.CODE) === (ch[0] || {}).code) || {};
|
||||
out.chg = {
|
||||
n: ch.length,
|
||||
nagSkipped: ch.every((c) => !nag.has(c.code)),
|
||||
first: ch[0]
|
||||
? [
|
||||
ch[0].to,
|
||||
Math.trunc(num(r0.MAMT1) * 1.1) +
|
||||
Math.trunc(num(r0.LAMT1) * 1.1) +
|
||||
Math.trunc(num(r0.SAMT1) * 1.1),
|
||||
]
|
||||
: null,
|
||||
idem: UA.chgAmt(rows, { kinds: "MLS", from: 1, to: 1, rate: 100, digits: 4 }).length,
|
||||
};
|
||||
// 할증 — 율 두 번 = 한 번 · 금액 갈래
|
||||
const h1 = UA.halAll(rows, { kinds: "M", how: "rate", value: 10, digits: 4 });
|
||||
const h2 = UA.halAll(rows, { kinds: "M", how: "rate", value: 10, digits: 4 });
|
||||
const h3 = UA.halAll(rows, { kinds: "M", how: "amt", value: 0, digits: 4 });
|
||||
out.hal = { n: h1.length, twice: h2.length, back: h3.length };
|
||||
// 초기화 — 호환규정코드(재료비)
|
||||
const filled = rows.filter((r) => txt(r.CODE)[0] === "M" && txt(r.JDCODE)).length;
|
||||
out.init = { filled, cleared: UA.initCols(rows, { JD: "M" }).JDCODE || 0 };
|
||||
out.jdLeft = rows.filter((r) => txt(r.CODE)[0] === "M" && txt(r.JDCODE)).length;
|
||||
// 호환규정코드 찾기 — 원본 행에서 지운 값을 같은 명칭·규격 자료에서 다시 찾음
|
||||
const src = load.costn_rows.filter((r) => txt(r.JDCODE));
|
||||
out.jd = src.slice(0, 40).map((r) => {
|
||||
const c = UA.jdCandidates({ NAME: r.NAME, SIZE: r.SIZE }, src, "NS");
|
||||
return c.length > 0 && c.some((x) => x.jd === txt(r.JDCODE));
|
||||
});
|
||||
out.jdDup = UA.jdDup(load.costn_rows).length;
|
||||
// 자료파일 업데이트 — 호환규정코드 지운 사본 ← 원본(명칭/규격/단위 매칭 · 복사처 우선) · 명칭·규격·단위가 하나뿐인 행은 원래 코드로
|
||||
const blank = JSON.parse(JSON.stringify(load.costn_rows));
|
||||
blank.forEach((r) => (r.JDCODE = ""));
|
||||
const up = UM.mrgBsc(blank, load.costn_rows, {
|
||||
keys: ["NSU"],
|
||||
name: 0,
|
||||
amt: 0,
|
||||
jd: 2,
|
||||
kinds: "LMSW",
|
||||
});
|
||||
const key = (r) => `${txt(r.CODE)[0]}|${txt(r.NAME)}|${txt(r.SIZE)}|${txt(r.UNIT)}`;
|
||||
const cnt = {};
|
||||
load.costn_rows.forEach((r) => (cnt[key(r)] = (cnt[key(r)] || 0) + 1));
|
||||
const uniq = load.costn_rows.filter(
|
||||
(r) => "LMSW".includes(txt(r.CODE)[0]) && txt(r.NAME) && txt(r.JDCODE) && cnt[key(r)] === 1,
|
||||
);
|
||||
out.mrg = {
|
||||
n: up.length,
|
||||
uniq: uniq.length,
|
||||
same: uniq.every(
|
||||
(r) => txt(blank.find((b) => txt(b.CODE) === txt(r.CODE)).JDCODE) === txt(r.JDCODE),
|
||||
),
|
||||
};
|
||||
// 손료계수 — 중기 구성의 천원 단위 기계가격 줄 수량 = 계 × 10^-7 × 1000
|
||||
const by = Object.fromEntries(load.costn_rows.map((r) => [txt(r.CODE), r]));
|
||||
const lossRows = (load.bdqty_rows || []).filter(
|
||||
(r) =>
|
||||
/^X/.test(txt(r.BDCODE)) &&
|
||||
by[txt(r.CODE)] &&
|
||||
txt(by[txt(r.CODE)].UNIT) === "천원" &&
|
||||
num(r.QTY1) > 0,
|
||||
);
|
||||
out.loss = lossRows.map((r) => {
|
||||
const s = UA.lossSum(num(r.QTY1), 100);
|
||||
return [num(r.QTY1), s, UA.lossQty(s, 100)];
|
||||
});
|
||||
const dec = new TextDecoder("euc-kr");
|
||||
out.special = UA.SPECIAL.map((_x, i) => UA.specialChars(i, (b) => dec.decode(b)).length);
|
||||
out.special0 = UA.specialChars(6, (b) => dec.decode(b))
|
||||
.slice(0, 6)
|
||||
.join("");
|
||||
return out;
|
||||
}
|
||||
|
||||
// 코드병합 — 복사원 = 다른 프로젝트 · 내역에 쓰인 구성 있는 일위대가 하나를 모든 하위 포함으로 가져옴
|
||||
function merge({ load, src }) {
|
||||
const txt = (v) => String(v == null ? "" : v).trim();
|
||||
const d = JSON.parse(JSON.stringify(load));
|
||||
const env = { d, state: d.sim_state, memo: {} };
|
||||
const t0 = Calc.computeAll(d.sim_state).boqComputed.totals.directT;
|
||||
const used = new Set(src.boq_rows.map((r) => txt(r.BDCODE1)));
|
||||
const pick = src.costn_rows
|
||||
.map((r) => txt(r.CODE))
|
||||
.find((c) => c[0] === "B" && used.has(c) && src.bdqty_rows.some((q) => txt(q.BDCODE) === c));
|
||||
const want = UM.closure(src, [pick], 2);
|
||||
const steps = UM.plan(env, src, { codes: [pick], mode: 2, conn: 2, policy: {} });
|
||||
const res = UM.apply(env, src, steps);
|
||||
const top = steps.find((s) => s.src === pick);
|
||||
const comp = (rows, c) => rows.filter((q) => txt(q.BDCODE) === c).map((q) => txt(q.CODE));
|
||||
const by = Object.fromEntries(d.costn_rows.map((r) => [txt(r.CODE), r]));
|
||||
const srcBy = Object.fromEntries(src.costn_rows.map((r) => [txt(r.CODE), r]));
|
||||
// 가져온 구성 자식 = 복사원 자식을 계획대로 바꾼 것 · 자식 명칭이 복사원과 같음
|
||||
const kidsOk = comp(src.bdqty_rows, pick).every((c, i) => {
|
||||
const dc = comp(d.bdqty_rows, top.dest)[i];
|
||||
return (
|
||||
dc === (res.map[c] || c) && (!srcBy[c] || !by[dc] || txt(by[dc].NAME) === txt(srcBy[c].NAME))
|
||||
);
|
||||
});
|
||||
const ctx = {
|
||||
state: d.sim_state,
|
||||
dbfData: d,
|
||||
currentSourcePath: load.source_path,
|
||||
uiState: { editedSmtMap: env.memo },
|
||||
};
|
||||
if (window.STMATE_ENGINE) window.STMATE_ENGINE.cascadeRecalculateAll(ctx);
|
||||
const tNew = Calc.num((by[top.dest] || {}).TAMT1);
|
||||
const p = window.STMATE_VIEW_PRJ_BOQ.buildSavePayload(
|
||||
Object.assign(ctx, { computed: Calc.calculateAll(ctx.state) }),
|
||||
);
|
||||
return {
|
||||
pick,
|
||||
n: want.length,
|
||||
steps: steps.length,
|
||||
acts: res.count,
|
||||
unresolved: res.unresolved,
|
||||
top,
|
||||
kidsOk,
|
||||
t0,
|
||||
t1: Calc.computeAll(d.sim_state).boqComputed.totals.directT,
|
||||
price: [Calc.num(srcBy[pick].TAMT1), tNew],
|
||||
payload: Object.assign(p, UC.structPayload(d)),
|
||||
};
|
||||
}
|
||||
|
||||
// 일위대가 풀기(wM_Expnd10) — 할증·비목별 끝전·손료 추가입력(PR 비율 줄)
|
||||
function expand(load) {
|
||||
const B = require(D + "ui_boq.js");
|
||||
const T = require(D + "ui_boq_tools.js");
|
||||
const txt = (v) => String(v == null ? "" : v).trim();
|
||||
const d = JSON.parse(JSON.stringify(load));
|
||||
d.boq_deleted = [];
|
||||
const m = { rows: d.boq_rows, items: d.sim_state.boqItems, deleted: d.boq_deleted };
|
||||
const by = Object.fromEntries(d.costn_rows.map((r) => [txt(r.CODE), r]));
|
||||
const comps = (c) => d.bdqty_rows.filter((q) => txt(q.BDCODE) === c);
|
||||
const ok = (q) =>
|
||||
by[txt(q.CODE)] && !/%$/.test(txt(by[txt(q.CODE)].UNIT)) && txt(q.CODE) !== "PR";
|
||||
const row = d.boq_rows.find(
|
||||
(r) => /^B/.test(txt(r.BDCODE1)) && Number(r.QTY1) > 0 && comps(txt(r.BDCODE1)).some(ok),
|
||||
);
|
||||
if (!row) return { skip: true };
|
||||
const Q = Number(row.QTY1);
|
||||
const o = {
|
||||
level: 0,
|
||||
hal: { L: 1.2, M: 1 },
|
||||
per: {
|
||||
L: { mode: "round", digits: 2 },
|
||||
M: { mode: "trunc", digits: 3 },
|
||||
X: { mode: "trunc", digits: 4 },
|
||||
W: { mode: "trunc", digits: 4 },
|
||||
},
|
||||
adds: [{ name: "공구손료", pct: 3, prin: 4, prout: 3 }],
|
||||
};
|
||||
const res = T.expandIlwi(m, row._recno, d.bdqty_rows, by, o, d.start_ini);
|
||||
const want = comps(txt(row.BDCODE1))
|
||||
.filter(ok)
|
||||
.map((q) => {
|
||||
const c = txt(q.CODE),
|
||||
k = "MLX".includes(c[0]) ? c[0] : "W";
|
||||
return [
|
||||
c,
|
||||
T.roundTo(Number(q.QTY1) * (c[0] === "L" ? 1.2 : 1) * Q, o.per[k].digits, o.per[k].mode),
|
||||
];
|
||||
});
|
||||
const comp = Calc.computeAll(d.sim_state).boqComputed.rows;
|
||||
const pr = comp.find((r) => r.code === "PR" && r.name === "공구손료");
|
||||
const at = d.boq_rows.findIndex((r) => txt(r.BDCODE1) === "PR" && r.NAME1 === "공구손료");
|
||||
const body = d.boq_rows.slice(at - want.length, at).map((r) => [txt(r.BDCODE1), Number(r.QTY1)]);
|
||||
const bodyL = comp
|
||||
.filter((r) => body.some((b, i) => r._recno === d.boq_rows[at - want.length + i]._recno))
|
||||
.reduce((a, r) => a + r.mAmt + r.lAmt + r.sAmt, 0);
|
||||
return {
|
||||
ok: res.ok,
|
||||
want,
|
||||
body,
|
||||
prS: pr ? pr.sAmt : null,
|
||||
prWant: Math.floor((bodyL * 3) / 100),
|
||||
};
|
||||
}
|
||||
|
||||
// 비목별 단가 끝전(wM_Rate) — 노무비 10원 미만 절사 · 재료비 그대로
|
||||
function units(load) {
|
||||
const B = require(D + "ui_boq.js");
|
||||
const T = require(D + "ui_boq_tools.js");
|
||||
const d = JSON.parse(JSON.stringify(load));
|
||||
const m = { rows: d.boq_rows, items: d.sim_state.boqItems, deleted: [] };
|
||||
const before = Calc.computeAll(d.sim_state).boqComputed.rows.filter(
|
||||
(r) => r._recno !== undefined && r.code !== "PR",
|
||||
);
|
||||
const u = Object.fromEntries(
|
||||
before.map((r) => [r._recno, { M: r.mUnit, L: r.lUnit, S: r.sUnit }]),
|
||||
);
|
||||
const res = T.roundUnits(m, null, u, { L: { mode: "trunc", digits: -1 } });
|
||||
const after = new Map(Calc.computeAll(d.sim_state).boqComputed.rows.map((r) => [r._recno, r]));
|
||||
const bad = before.filter((r) => {
|
||||
const a = after.get(r._recno);
|
||||
return (
|
||||
a &&
|
||||
!(a.lUnit === Math.trunc(r.lUnit / 10) * 10 && a.mUnit === r.mUnit && a.sUnit === r.sUnit)
|
||||
);
|
||||
}).length;
|
||||
return {
|
||||
changed: res.changed,
|
||||
want: before.filter(
|
||||
(r) => r.lUnit % 10 !== 0 && B.isLeaf(d.boq_rows.find((x) => x._recno === r._recno) || {}),
|
||||
).length,
|
||||
bad,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"""STMATE 재현 앱 저장 — % 줄(조달수수료 M%·T% · 비율 줄 PR) 다시 셈.
|
||||
|
||||
밑수 줄 수량을 고치면 % 줄 단가 칸(밑수)과 금액(밑수 × 율 원 미만 절사)이 따라 바뀜.
|
||||
손대지 않은 % 줄 재계산 = 원본 저장값(실무 STC 7건) 그대로.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from decimal import ROUND_DOWN, Decimal
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "tools/stmate-test"))
|
||||
from dbf_rw import DbfTable # noqa: E402
|
||||
from stc_archive import files_of, read_entries # noqa: E402
|
||||
from stc_save import SaveLog, _pct_rows # noqa: E402
|
||||
|
||||
DOCS = ROOT / "resources/knowledge/original/실무문서"
|
||||
STCS = sorted(DOCS.rglob("*.STC"))
|
||||
|
||||
|
||||
def _boq(p):
|
||||
f = files_of(read_entries(p.read_bytes()))
|
||||
return DbfTable(f["BOQ11.DBF"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stc", STCS, ids=[p.stem[:20] for p in STCS])
|
||||
def test_pct_rows_recompute_equals_stored(stc):
|
||||
bt = _boq(stc)
|
||||
log = SaveLog()
|
||||
_pct_rows(bt, "BOQ11.DBF", "1", log)
|
||||
assert not log.items, f"{stc.stem} % 줄 재계산이 저장값과 다름 {log.items[:4]}"
|
||||
|
||||
|
||||
def test_pct_row_follows_base_edit():
|
||||
stc = next(p for p in STCS if "현동" in p.stem)
|
||||
bt = _boq(stc)
|
||||
i = next(k for k in range(len(bt)) if bt.get(k, "UNIT1").strip() == "M%")
|
||||
j = next(k for k in range(i - 1, -1, -1) if float(bt.get(k, "M_AMT1") or 0) > 0)
|
||||
base0 = Decimal(bt.get(i, "MAMT1"))
|
||||
bt.set(j, "M_AMT1", Decimal(bt.get(j, "M_AMT1")) + 1000)
|
||||
_pct_rows(bt, "BOQ11.DBF", "1", SaveLog())
|
||||
assert Decimal(bt.get(i, "MAMT1")) == base0 + 1000
|
||||
q = Decimal(bt.get(i, "QTY1"))
|
||||
want = ((base0 + 1000) * q / 100).quantize(Decimal(1), rounding=ROUND_DOWN)
|
||||
assert Decimal(bt.get(i, "M_AMT1")) == want
|
||||
@@ -0,0 +1,194 @@
|
||||
"""STMATE 재현 앱 코드 관리(신규등록·코드변경·+α·항목교체·삭제) · 단가 일괄 손질 지킴 시험.
|
||||
|
||||
계산 = tools/stmate-test/ui_code.js · ui_code_amt.js · ui_merge.js(node)
|
||||
저장 = api_ui_code.py(/api/ui/save) → 작업본 다시 불러 대조.
|
||||
자료 = 실무 STC 7건(server.api_load) — 정답은 원본 저장값(참조 개수·구성행·직공비)에서 읽음.
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "tools/stmate-test"))
|
||||
import api_ui_code # noqa: E402
|
||||
import server # noqa: E402
|
||||
|
||||
HELPER = Path(__file__).with_name("helper_stmate_ui_code.cjs")
|
||||
STCS = sorted((ROOT / "resources/knowledge/original/실무문서").rglob("*.STC"))
|
||||
IDS = [p.stem[:16] for p in STCS]
|
||||
_LOAD: dict = {}
|
||||
|
||||
|
||||
def load(stc):
|
||||
if stc not in _LOAD:
|
||||
_LOAD[stc] = server.api_load(server.LoadReq(source_path=str(stc)))
|
||||
return _LOAD[stc]
|
||||
|
||||
|
||||
def node(data, mode, **extra):
|
||||
raw = json.dumps(dict({"load": data, "mode": mode}, **extra), ensure_ascii=False, default=str)
|
||||
out = subprocess.check_output(["node", str(HELPER)], input=raw, text=True, encoding="utf-8")
|
||||
return json.loads(out)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def work(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(server, "WORK_DBF_ROOT", tmp_path)
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stc", STCS, ids=IDS)
|
||||
def test_code_ops_keep_direct_cost_and_refs(stc):
|
||||
r = node(load(stc), "ops")
|
||||
a, c, rp = r["add"], r["change"], r["replace"]
|
||||
assert a["ok"] and a["comps"][0] == a["comps"][1] > 0 and a["t"] == r["t0"]
|
||||
assert c["ok"] and c["bad"] and c["t"] == r["t0"]
|
||||
for (old, new), b in zip(c["map"].items(), c["before"].values()):
|
||||
assert c["after"][new] == b and sum(b.values()) > 0 # 참조 개수 그대로 옮김
|
||||
assert all(sum(g.values()) == 0 for g in c["gone"]) # 옛 코드는 어디에도 안 남음
|
||||
assert rp["ok"] and rp["t"] == r["t0"] and rp["refused"] and rp["del"]
|
||||
assert r["plus"]["ok"] and r["plus"]["same"] and r["plus"]["compOk"]
|
||||
assert r["unused"]["plus"] >= r["unused"]["minus"] and r["needs"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stc", STCS, ids=IDS)
|
||||
def test_ui_save_writes_structure_and_reloads(stc, work):
|
||||
data = load(stc)
|
||||
r = node(data, "ops")
|
||||
p = r["payload"]
|
||||
res = api_ui_code.ui_save(api_ui_code.UiSaveReq(**p))
|
||||
back = server.api_load(server.LoadReq(source_path=res["saved_dir"]))
|
||||
codes = {x["CODE"].strip() for x in back["costn_rows"]}
|
||||
new_b = r["add"]["code"]
|
||||
assert new_b in codes and r["add"]["src"] not in codes # 복사본 남고 원래 것 지움
|
||||
for old, new in r["change"]["map"].items():
|
||||
assert new in codes and old not in codes
|
||||
boq = sum(1 for x in back["boq_rows"] if x["BDCODE1"].strip() == new)
|
||||
comp = sum(1 for x in back["bdqty_rows"] if x.get("CODE", "").strip() == new)
|
||||
b = r["final"][new] # 항목교체·삭제 뒤 화면 자료의 참조 개수
|
||||
assert (boq, comp) == (b["boq"], b["comp"])
|
||||
memo = [k for k, m in back["costn_memos"].items() if f"&{new}" in m["text"]]
|
||||
assert len(memo) == b["memo"]
|
||||
assert (
|
||||
sum(1 for x in back["bdqty_rows"] if x.get("BDCODE", "").strip() == new_b)
|
||||
== r["finalComps"]
|
||||
)
|
||||
assert node(back, "direct")["direct"] == r["t0"] # 다시 불러도 직공비 그대로
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stc", STCS[:3], ids=IDS[:3])
|
||||
def test_ui_save_without_struct_changes_nothing(stc, work):
|
||||
data = load(stc)
|
||||
files = server.read_project_files(str(stc))
|
||||
struct = [{"_recno": r["_recno"], "CODE": r["CODE"]} for r in data["costn_rows"]]
|
||||
p = {"source_path": str(stc), "costn_struct": struct, "costn_deleted": [], "boq_code_rows": []}
|
||||
out, log = api_ui_code.apply_struct(files, p)
|
||||
assert log == [] and all(out[k] is files[k] for k in files) # 코드·칸 그대로면 원래 바이트
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stc", STCS, ids=IDS)
|
||||
def test_amount_tools(stc):
|
||||
r = node(load(stc), "amt")
|
||||
ch = r["chg"]
|
||||
assert ch["nagSkipped"] and ch["idem"] == 0
|
||||
if ch["first"]:
|
||||
assert ch["first"][0] == ch["first"][1]
|
||||
assert (
|
||||
r["hal"]["twice"] == 0 and r["hal"]["back"] == r["hal"]["n"]
|
||||
) # 기준단가 기준 — 같은 할증 두 번 = 한 번
|
||||
assert r["init"]["cleared"] == r["init"]["filled"] and r["jdLeft"] == 0
|
||||
assert all(r["jd"]) # 같은 명칭·규격 자료에서 원래 호환규정코드를 다시 찾음
|
||||
assert (
|
||||
r["mrg"]["same"] and r["mrg"]["n"] >= r["mrg"]["uniq"]
|
||||
) # 자료파일 업데이트 — 조달코드 되살림
|
||||
for q, s, q2 in r["loss"]:
|
||||
assert abs(s - round(s)) < 1e-6 and abs(q2 - q) < 1e-9 # 계(10^-7) 정수 · 왕복 같음
|
||||
assert all(n > 0 for n in r["special"]) and len(r["special"]) == 14
|
||||
|
||||
|
||||
PAIRS = [(0, 1), (1, 4), (4, 0)] # 복사처 ← 복사원(다른 원설계 프로젝트)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("a,b", PAIRS)
|
||||
def test_merge_code_from_other_project(a, b, work):
|
||||
dst, src = load(STCS[a]), load(STCS[b])
|
||||
r = node(dst, "merge", src=src)
|
||||
assert (
|
||||
r["steps"] == r["n"] > 0 and r["kidsOk"] and r["t1"] == r["t0"]
|
||||
) # 가져와도 내역 직공비 그대로
|
||||
assert r["acts"]["new"] + r["acts"]["map"] == r["steps"]
|
||||
res = api_ui_code.ui_save(api_ui_code.UiSaveReq(**r["payload"]))
|
||||
back = server.api_load(server.LoadReq(source_path=res["saved_dir"]))
|
||||
top = r["top"]
|
||||
row = next(x for x in back["costn_rows"] if x["CODE"].strip() == top["dest"])
|
||||
assert row["NAME"].strip() and abs(float(row["TAMT1"] or 0) - r["price"][1]) < 1
|
||||
kids = [x for x in back["bdqty_rows"] if x.get("BDCODE", "").strip() == top["dest"]]
|
||||
assert len(kids) == sum(
|
||||
1 for x in src["bdqty_rows"] if x.get("BDCODE", "").strip() == r["pick"]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stc", STCS, ids=IDS)
|
||||
def test_expand_with_surcharge_rounding_and_loss_row(stc):
|
||||
r = node(load(stc), "expand")
|
||||
if r.get("skip"):
|
||||
pytest.skip("구성 있는 일위대가 내역 행 없음")
|
||||
assert r["ok"] and r["body"] == r["want"] # 비목별 끝전·노무 할증 1.2
|
||||
assert r["prS"] == r["prWant"] > 0 # 공구손료 = 풀린 줄 전체 금액 × 3% → 경비(원 미만 절사)
|
||||
|
||||
|
||||
def test_project_data_search_finds_names_across_projects(monkeypatch):
|
||||
paths = [str(p) for p in STCS[:3]]
|
||||
projs = [{"name": p.stem, "path": str(p)} for p in STCS[:3]]
|
||||
monkeypatch.setattr(server, "discover_sources", lambda: {"projects": projs})
|
||||
a = load(STCS[0])
|
||||
row = next(r for r in a["costn_rows"] if r["CODE"].startswith("L") and r["NAME"].strip())
|
||||
words = row["NAME"].split()
|
||||
res = api_ui_code.ui_search(api_ui_code.SearchReq(kind="LMS", kw=" ".join(words), paths=paths))
|
||||
hits = {(r["project"], r["CODE"]) for r in res["rows"]}
|
||||
assert (STCS[0].stem, row["CODE"].strip()) in hits and not res["errors"]
|
||||
assert all(
|
||||
all(w.lower() in f"{r['NAME']} {r['SIZE']}".lower() for w in words) for r in res["rows"]
|
||||
)
|
||||
boq = api_ui_code.ui_search(api_ui_code.SearchReq(kind="BOQ", kw=words[0], paths=paths))
|
||||
assert all(words[0].lower() in f"{r['NAME1']} {r['SIZE1']}".lower() for r in boq["rows"])
|
||||
jd = next(r for r in a["costn_rows"] if r["JDCODE"].strip())
|
||||
res = api_ui_code.ui_search(
|
||||
api_ui_code.SearchReq(
|
||||
kind=jd["CODE"][0] in "LMS" and "LMS" or "XBD",
|
||||
kw=jd["JDCODE"].strip(),
|
||||
field="jdcode",
|
||||
paths=paths[:1],
|
||||
)
|
||||
)
|
||||
assert jd["CODE"].strip() in {r["CODE"] for r in res["rows"]}
|
||||
|
||||
|
||||
OTHER_BOQ = [
|
||||
(stc, str(r["PCODE"]).strip())
|
||||
for stc in STCS
|
||||
for r in server.api_load(server.LoadReq(source_path=str(stc)))["prjlst_all"]
|
||||
if str(r["PCODE"]).strip() != "11"
|
||||
and f"BOQ{str(r['PCODE']).strip()}.DBF" in server.read_project_files(str(stc))
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stc,pcode", OTHER_BOQ, ids=[f"{s.stem[:10]}-{p}" for s, p in OTHER_BOQ])
|
||||
def test_open_other_boq_table(stc, pcode):
|
||||
d = api_ui_code.ui_load_boq(api_ui_code.LoadBoqReq(source_path=str(stc), pcode=pcode))
|
||||
assert d["boq_table_name"] == f"BOQ{pcode}.DBF" and d["prjlst"]["PCODE"].strip() == pcode
|
||||
assert d["sim_state"]["projectMeta"]["pcode"].strip() == pcode
|
||||
t = node(d, "direct")["direct"]
|
||||
assert abs(t - float(d["prjlst"]["TCOST1"] or 0)) <= 1 # 그 공사 저장 직접비(TCOST1)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stc", STCS, ids=IDS)
|
||||
def test_unit_rounding_per_kind(stc):
|
||||
r = node(load(stc), "units")
|
||||
assert (
|
||||
r["bad"] == 0 and r["changed"] == r["want"]
|
||||
) # 노무비만 10원 미만 절사 · 재료비·경비 그대로
|
||||
+26
-25
@@ -19,6 +19,7 @@
|
||||
- `test_stmate_kanjub_opts.py` · `test_stmate_kanjub_rate.py` — 제잡비 선택지 손셈 대조 · 요율표 조회로 저장 요율 재현
|
||||
- `test_stmate_smt_lang.py` · `test_stmate_qty_sheet.py` — 식 함수·변수·문법 검사 · 수량산출서·QCODE
|
||||
- `test_stmate_ui.py` — 메뉴·내역서 편집·도구 창 순수 계산
|
||||
- `test_stmate_conv_*.py` · `test_stmate_file_pct.py` · `test_stmate_ui_code.py` — 엑셀·XML 가져오기 왕복 · 분리형 변경 STC · % 줄 저장 재계산 · 코드 관리 왕복
|
||||
- `test_stmate_file_*.py` — 실무 STC 7건 무손실 왕복(손대지 않은 저장 = 원본 바이트) · 고친 칸만 바뀜 · 새 공사·복사등록·해당분 압축
|
||||
- `test_stmate_stage_*.py` — 설계변경(영덕·소광 변경 BOQ·엑셀 대조) · 계약 변환(울진 BOQ12 계약내역·총공사비 1원) · 기성·실행(앞뒤 맞춤만)
|
||||
- `test_stmate_ilwi_eval.py` — 일위대가·중기 구성 재계산(원설계 5건 + 영덕 변경) 1원 · 연쇄 재계산(기초단가 → 원가계산서) 원설계 5건 전 단가·총공사비 그대로
|
||||
@@ -27,16 +28,16 @@
|
||||
|
||||
## 진행률
|
||||
|
||||
- 합계: 전체 246 · 구현 149 · 부분 78 · 없음 19 → 진행률 76.4%((149 + 78×0.5) / 246) · 구현만 세면 60.6%
|
||||
- 합계: 전체 246 · 구현 167 · 부분 74 · 없음 5 → 진행률 82.9%((167 + 74×0.5) / 246) · 구현만 세면 67.9%
|
||||
|
||||
### G1 화면·메뉴 (구현 16 · 부분 23 · 없음 15) — 50.9%
|
||||
### G1 화면·메뉴 (구현 34 · 부분 19 · 없음 1) — 80.6%
|
||||
|
||||
- [x] F1-01 메인 메뉴·툴바·하단 금액 집계줄 — STmate.Cpm 메인 · 현재: 구현(원본 메뉴 구조·툴바 차례·단축키 12·체크 항목·최근 폴더(test_stmate_ui))
|
||||
- [ ] F1-02 공사목록표 목록·선택·불러오기 — wPrjList · 현재: 부분(공사목록표·등록정보 수정 · 다른 내역서 열기 없음)
|
||||
- [x] F1-02 공사목록표 목록·선택·불러오기 — wPrjList · 현재: 구현(공사목록표 · 다른 내역서 열기(저장 직접비 1원 일치))
|
||||
- [ ] F1-03 프로젝트 신규 등록·삭제 — wM_Add_Prj · ST_U_DelPrj · 현재: 부분(stc_project 새 공사(빈 상태) · 삭제 화면 없음)
|
||||
- [ ] F1-04 프로젝트 등록정보 수정 — wPrjList · 현재: 부분(공사목록표·등록정보 수정 · 다른 내역서 열기 없음)
|
||||
- [x] F1-04 프로젝트 등록정보 수정 — wPrjList · 현재: 구현(등록정보 수정)
|
||||
- [ ] F1-05 복사등록(차수·단계 변환) — wPrjList · ST_PList · 현재: 부분(stc_project 복사등록 차수 인계(K_ORD·직공비·원가 설정) 실물 대조 · 계약 변환 실물 대조 없음)
|
||||
- [ ] F1-06 프로젝트 DATA 검색(노무~내역서 8종) — wSrch_PL · 현재: 없음
|
||||
- [x] F1-06 프로젝트 DATA 검색(노무~내역서 8종) — wSrch_PL · 현재: 구현(등록 프로젝트 전수 검색·찾아가기·가져오기)
|
||||
- [x] F1-07 공종목록표 계층 내역 그리드(T/E/C 짝 집계) — wBoq · 현재: 구현(view_prj_boq 37~145)
|
||||
- [x] F1-08 내역 칸 편집 — wBoq · 현재: 구현(내역서 칸 편집·행 넣기·지우기·이동·복사·붙여넣기 · 직공비 증감 정확(test_stmate_ui))
|
||||
- [x] F1-09 내역 행 추가·끼워넣기 — wBoq · 현재: 구현(내역서 칸 편집·행 넣기·지우기·이동·복사·붙여넣기 · 직공비 증감 정확(test_stmate_ui))
|
||||
@@ -45,46 +46,46 @@
|
||||
- [x] F1-12 계표시 레벨 상향·하향, 주석 → 계시작 변환 — wBoq · 현재: 구현(레벨 거르기·계표시·레벨 올리기/내리기·주공종 집계)
|
||||
- [x] F1-13 내역 찾기 — wM_FindBoq · 현재: 구현(app.js 344)
|
||||
- [x] F1-14 호표 코드 → 대가표·단가표 이동 — wBoq · 현재: 구현(코드 → 단가목록표·대가표 이동)
|
||||
- [ ] F1-15 공종 목록(코드·명칭·분류 정렬, 대공종 필터) — wKongJong · 현재: 부분(공종목록에서 코드 넣기)
|
||||
- [x] F1-15 공종 목록(코드·명칭·분류 정렬, 대공종 필터) — wKongJong · 현재: 구현(공종목록 코드 넣기 · 대공종 옵션)
|
||||
- [x] F1-16 주공종 집계표(레벨별 계표시 확인) — wMBoq · 현재: 구현(레벨 거르기·계표시·레벨 올리기/내리기·주공종 집계)
|
||||
- [ ] F1-17 공종번호 자동입력(머리·끝 글자·번호형식·자리수) — wITNum · 현재: 부분(공종번호 자동입력 · 원본 번호 빈 자리 앞까지 재현(봉화·영월))
|
||||
- [ ] F1-18 내역 할증율 적용(전체/비목별) — wBoqRate · 현재: 부분(내역 할증율 계산 반영(calcBoq) · 근거 등급 C)
|
||||
- [ ] F1-19 비목별 일괄 보정(절사/반올림 × 소수 0~4 · 범위) — wM_Rate · 현재: 부분(수량 단수 보정만)
|
||||
- [x] F1-19 비목별 일괄 보정(절사/반올림 × 소수 0~4 · 범위) — wM_Rate · 현재: 구현(비목별 단가 끝전 · 줄 고정 단가(server fixedUnit)로 다시 불러도 유지)
|
||||
- [x] F1-20 일위대가표 풀기 — wExpnd · 현재: 구현(일위대가 풀기 = 구성표 × 내역 수량)
|
||||
- [ ] F1-21 풀어넣기 비목별 끝전·가산비목 지정 — wM_Expnd · wM_Expnd10 · 현재: 없음
|
||||
- [ ] F1-22 내역 동일코드 처리 — wSameCode · 현재: 없음
|
||||
- [x] F1-21 풀어넣기 비목별 끝전·가산비목 지정 — wM_Expnd · wM_Expnd10 · 현재: 구현(풀어넣기 비목별 끝전·할증·손료 비율 줄 · 손료 줄 금액 7건 대조)
|
||||
- [x] F1-22 내역 동일코드 처리 — wSameCode · 현재: 구현(동일코드 행 모아보기 · 행 이동)
|
||||
- [x] F1-23 내역보관함 (1)(2)(3) — wBoq · 현재: 구현(보관함 (1)(2)(3))
|
||||
- [ ] F1-24 단가목록표 코드종류 필터(10종) — wM_JabPum cBoxCode · 현재: 부분(코드 종류 10종 · 그림코드 앞 글자 근거 없음)
|
||||
- [x] F1-24 단가목록표 코드종류 필터(10종) — wM_JabPum cBoxCode · 현재: 구현(코드 종류 10종 · 그림코드 = I 코드(원본 화면 탭))
|
||||
- [x] F1-25 단가 찾기(명칭·규격 / 산근내용) — wM_JabPum cBox_IFORM · 현재: 구현(단가 찾기·사용처 찾기)
|
||||
- [ ] F1-26 기초단가 직접 수정(재·노·경) — wM_JabPum · 현재: 부분(기초단가 직접 수정·기준단가 수정·후보 채택 · 보호표시 칸 확인 안 됨)
|
||||
- [ ] F1-27 코드 신규 등록·삭제 — wM_JabPum · ST_U_DelCode · 현재: 없음
|
||||
- [ ] F1-28 코드 변경·치환(일괄·선택) — ST_U_ChgCode · 현재: 없음
|
||||
- [x] F1-27 코드 신규 등록·삭제 — wM_JabPum · ST_U_DelCode · 현재: 구현(ui_code 신규등록(복사)·삭제 · 저장·다시 불러 참조 개수·직공비 그대로 7건(test_stmate_ui_code))
|
||||
- [x] F1-28 코드 변경·치환(일괄·선택) — ST_U_ChgCode · 현재: 구현(코드변경 선택·일괄·+α · 항목교체 · 7건 왕복)
|
||||
- [ ] F1-29 기준단가 비율 일괄수정(보호표시·0원 제외) — wM_Edt_Hal · 현재: 부분(기초단가 직접 수정·기준단가 수정·후보 채택 · 보호표시 칸 확인 안 됨)
|
||||
- [ ] F1-30 할증 금액·율 두 갈래 편집 — ST_Jabpum Edt_Hal_Amt/Rate · 현재: 없음
|
||||
- [x] F1-30 할증 금액·율 두 갈래 편집 — ST_Jabpum Edt_Hal_Amt/Rate · 현재: 구현(할증 율·금액(기준단가 기준 · 두 번 해도 같은 값))
|
||||
- [ ] F1-31 가격지 후보 비교(1~5단가·최소단가) — wM_Boxa RG_MComp · 현재: 부분(기초단가 직접 수정·기준단가 수정·후보 채택 · 보호표시 칸 확인 안 됨)
|
||||
- [ ] F1-32 자재·공종 분류 필터, 불러간 목록 — wM_Boxa rg_FLT · wM_Box_H · 현재: 없음
|
||||
- [x] F1-32 자재·공종 분류 필터, 불러간 목록 — wM_Boxa rg_FLT · wM_Box_H · 현재: 구현(속성·명칭·불러간 목록 필터)
|
||||
- [x] F1-33 대가표 목록·선택(B/D/X) — wM_iLWi_San · 현재: 구현(view_dan_ilwi 236~)
|
||||
- [ ] F1-34 일위대가 구성행 표 편집·노임계수·저장비목 선택 — wM_Edit_iLWi · 현재: 부분(일위대가·중기 구성행 편집 화면 · BDQTY 저장 없음)
|
||||
- [x] F1-34 일위대가 구성행 표 편집·노임계수·저장비목 선택 — wM_Edit_iLWi · 현재: 구현(구성행 편집 · 저장 = BDQTY 바뀐 호표만 암호로 다시 씀(stc_save_extra))
|
||||
- [ ] F1-35 단가산출근거 편집 + 계산(F9) — wM_Edit_San · 현재: 부분(글 편집·즉시 계산, 함수·문법검사 없음)
|
||||
- [ ] F1-36 손료 계산(F6)·시간당 손료계수 수정 — wM_Edit_San · wM_Edit_XRate · 현재: 부분(손료 계산기 규칙(REA 30) · 반올림 방향 미확정)
|
||||
- [ ] F1-37 사용자 단가산출식 편집 — wM_Edit_USER · 현재: 없음
|
||||
- [ ] F1-38 실적단가 → 일식단가 변환등록 — wM_Conv_A2W · 현재: 부분(calc_a2w 비목율 정리(REA 27) · 금액 비목 나눔 근거 없음)
|
||||
- [ ] F1-39 호환규정코드 찾기·입력 — wM_JDCode · 현재: 없음
|
||||
- [ ] F1-36 손료 계산(F6)·시간당 손료계수 수정 — wM_Edit_San · wM_Edit_XRate · 현재: 부분(손료계수(수량 = 계 × 10⁻⁴ 정수 확인) · calc_xrate · 반올림 방향 미확정)
|
||||
- [x] F1-37 사용자 단가산출식 편집 — wM_Edit_USER · 현재: 구현(사용자문자 편집 목록)
|
||||
- [ ] F1-38 실적단가 → 일식단가 변환등록 — wM_Conv_A2W · 현재: 부분(calc_a2w 실적→일식 · 7건 실적단가 자료 없음)
|
||||
- [ ] F1-39 호환규정코드 찾기·입력 — wM_JDCode · 현재: 부분(조달청 코드 목록 암호 파일 · 다른 프로젝트 값 후보)
|
||||
- [x] F1-40 사용처 찾기 — ST_FindUse wM_FindUse · 현재: 구현(단가 찾기·사용처 찾기)
|
||||
- [ ] F1-41 코드 병합(4택)·데이터 병합(비목별 복사처/복사원/신규) — wM_Stmate_Merge_Code · uM_Merge · 현재: 없음
|
||||
- [x] F1-41 코드 병합(4택)·데이터 병합(비목별 복사처/복사원/신규) — wM_Stmate_Merge_Code · uM_Merge · 현재: 구현(ui_merge 코드병합 4택 · 데이타병합(복사처/복사원/신규 · 동일항목 3택 · W코드) · 저장·다시 불러 대조)
|
||||
- [ ] F1-42 자료파일 업데이트(매칭 5단계·명칭/단가/조달코드) — wM_Mrg_BSC · 현재: 없음(기준자료(BSC) 암호 ZIP·난독 DBF 로 못 읽음)
|
||||
- [ ] F1-43 단가 일괄변경·물가변동 — ST_U_ChgAmt · 현재: 없음
|
||||
- [x] F1-43 단가 일괄변경·물가변동 — ST_U_ChgAmt · 현재: 구현(ui_code_amt 일괄변경·물가변동 · 당초⇔변경 단가 옮기기)
|
||||
- [x] F1-44 초기값 입력 및 수정 — wM_Set_Sys · 현재: 구현(초기값 화면 전체 항목 · 저장 = Start8x.Ini 바뀐 키)
|
||||
- [ ] F1-45 프로젝트 계산 초기값 — wM_Set_Init · 현재: 없음
|
||||
- [x] F1-45 프로젝트 계산 초기값 — wM_Set_Init · 현재: 구현(프로젝트 계산 초기값 5칸 × 8종)
|
||||
- [ ] F1-46 직종·노임 설정 — wM_Set_JLab · 현재: 부분(조건 직종 코드 입력만)
|
||||
- [ ] F1-47 제잡비 계산 화면(4형식 탭) — wM_KanJub · 현재: 부분(차수·방식·공사종류·기간·고용등급·퇴직 6칸 처리 없음)
|
||||
- [ ] F1-48 제비율표 수정·취소·저장·출력 — wM_KAN_RATE · 현재: 부분(보기만, 고칠 칸 없음 view_kanjub_prt 152)
|
||||
- [ ] F1-49 요율표 불러오기·선택 — wM_KAN_RATE · 현재: 부분(RATE_R 탭만 바뀜, 원가 요율 미연결 app.js 510)
|
||||
- [ ] F1-50 잡비 금액 불러오기(타 프로젝트 금액을 밑수로) — wM_GetAMT · 현재: 없음
|
||||
- [x] F1-50 잡비 금액 불러오기(타 프로젝트 금액을 밑수로) — wM_GetAMT · 현재: 구현(잡비 금액 불러오기 → 제잡비 직접입력(C_ 칸))
|
||||
- [ ] F1-51 인쇄 메뉴 옵션(글꼴·코드필드·수량위치·양식·방향) — PM_Prt · 현재: 부분(scr_report_opts 인쇄 메뉴 옵션 화면)
|
||||
- [ ] F1-52 보고서 인쇄순서·인쇄 공종 집계수준 선택 — PM_Prn_Ord · PM_Sel_St · 현재: 부분(인쇄순서·집계수준 화면)
|
||||
- [ ] F1-53 인쇄영역 선택(당초/변경/분할·레벨·변경 1~8차) — wM_Sel_Prn · 현재: 부분(인쇄영역(당초/변경/분할·레벨·차수) 화면)
|
||||
- [ ] F1-54 특수문자·그림코드·메모 입력 — mMinMsg_ · wInDlgImg · wInDlgMemo · 현재: 없음
|
||||
- [ ] F1-54 특수문자·그림코드·메모 입력 — mMinMsg_ · wInDlgImg · wInDlgMemo · 현재: 부분(특수문자·메모 · 그림 넣기 없음)
|
||||
|
||||
### G2 계산 엔진 (구현 51 · 부분 9 · 없음 1) — 91.0%
|
||||
|
||||
@@ -108,7 +109,7 @@
|
||||
- [x] F2-18 비목우선/총액우선 차액 보정(OPTNM) — REA 15단계 · 현재: 구현(CHNNM 비목우선 = 실무 7건 차수별 내역 줄 금액 1원 · 총액우선 = OPTIMIZ 끝전 배분(손셈 대조) · 비율 줄(test_stmate_kanjub_boq))
|
||||
- [x] F2-19 계층 합산(SUM_TOT·T/E 짝) — 16번 §4.1 · 현재: 구현(같은 코드 여러 줄이면 짝 오류 위험 view_prj_boq 66)
|
||||
- [x] F2-20 제외금액 C* 구역 — 16번 §4.2 · 현재: 구현
|
||||
- [x] F2-21 관급자재·조달수수료 분리, 관급 천원 올림 — 16번 §4.3·§4.7 · 현재: 구현(kanjub_calc·kanjub_parts 원본 계산 순서 · 실무 7건 저장 결과키 전수 1원(test_kanjub_every_stored_key) · 표본 밖 선택지 손셈 대조(test_stmate_kanjub_opts))
|
||||
- [x] F2-21 관급자재·조달수수료 분리, 관급 천원 올림 — 16번 §4.3·§4.7 · 현재: 구현(관급·조달수수료 · 코드 붙은 % 줄 = 같은 상위 공종 앞 줄 합 × 율 계산·저장 재계산(test_stmate_file_pct · kanjub_stc))
|
||||
- [x] F2-22 요율표 규모·기간 구간 조회 — 12번 §8~10 · 06번 §2 · 현재: 구현(kanjub_calc·kanjub_parts 원본 계산 순서 · 실무 7건 저장 결과키 전수 1원(test_kanjub_every_stored_key) · 표본 밖 선택지 손셈 대조(test_stmate_kanjub_opts))
|
||||
- [x] F2-23 요율 기본값 처리 — calc.js · 현재: 구현(num 기본값 · 4건 시험)
|
||||
- [ ] F2-24 공사종류·적용기준(행자부 RateM 포함)·공사기간 축 — 12번 §10.1 · 현재: 부분(공사종류·기간 축 요율 조회 7건 재현 · 행자부 적용기준 근거 부족)
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""코드 관리·자료 병합 저장 끼워 넣기 API.
|
||||
|
||||
코드 신규·삭제·변경·항목교체·병합 뒤 저장 · 프로젝트 DATA 검색 · 다른 내역서 열기.
|
||||
근거: ST_U_ChgCode.CPM(uM_ChgCode · uM_Chg_Plus · uM_ReplCode) · ST_U_DelCode.CPM(uM_DelCode)
|
||||
· ST_U_Merge.CPM(uM_Merge) · ST_Merge_Code.CPM(wM_Stmate_Merge_Code · wSrch_PL)
|
||||
— DFM 원문(C:/경영정보(주)/System)
|
||||
저장 = 구조 손질(COSTN 행 끼움·지움·코드 · 다른 내역 표 코드)
|
||||
→ stc_save.apply_save(바뀐 칸만 · 구성표 bdqty_rows 포함)
|
||||
요청 덧붙임 키:
|
||||
costn_struct [{_recno | _new+_from|_vals, CODE, 덧붙임 칸…}] — 있는 행은 코드·덧붙임 칸만,
|
||||
새 행은 _from 행 복사 뒤 코드 · _vals(다른 프로젝트에서 가져온 행의 칸 값)
|
||||
costn_deleted [_recno…] — 지움 표시(*) · 원래 자리 유지
|
||||
boq_code_rows [{table, _recno, BDCODE1~4}] — 열린 표 밖 내역 표의 코드 바꿈
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
|
||||
from dbf_rw import DbfTable
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from smt_parser import parse_dbf_bytes
|
||||
from stc_project import safe_name, write_output
|
||||
from stc_save import apply_save, pick_boq_table
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# 덧붙임 칸 — 기본 저장(COSTN_SAVE_COL) 밖이나 코드 관리 화면이 고치는 칸
|
||||
EXTRA_COL = ("BQCODE", "EQCODE", "JDCODE", "WQCODE", "EDT_DATE", "NAME2", "SIZE2", "PP", "CONTCODE")
|
||||
|
||||
|
||||
def _txt(v) -> str:
|
||||
return "" if v is None else str(v).strip()
|
||||
|
||||
|
||||
def _same(tbl: DbfTable, i: int, name: str, value) -> bool:
|
||||
"""뜻으로 같은가 — 숫자 칸이 깨진 값이어도 글자로 먼저 견줌."""
|
||||
if _txt(tbl.get(i, name)) == _txt(value):
|
||||
return True
|
||||
try:
|
||||
return tbl.same_value(i, name, value)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _put(tbl: DbfTable, i: int, name: str, value, log: list, table: str) -> None:
|
||||
if name not in tbl.fields or tbl.fields[name].type == "M" or _same(tbl, i, name, value):
|
||||
return
|
||||
old = tbl.get(i, name)
|
||||
tbl.set_raw(i, name, tbl.encode_value(name, value))
|
||||
log.append({"table": table, "recno": i, "field": name, "old": old, "new": _txt(value)})
|
||||
|
||||
|
||||
def _costn(files: dict, req: dict, log: list) -> None:
|
||||
struct = req.get("costn_struct") or []
|
||||
dele = {int(x) for x in (req.get("costn_deleted") or [])}
|
||||
if not (struct or dele) or "COSTN.DBF" not in files:
|
||||
return
|
||||
ct = DbfTable(files["COSTN.DBF"])
|
||||
n0 = len(ct)
|
||||
for i in sorted(dele):
|
||||
if 0 <= i < n0 and not ct.deleted(i):
|
||||
ct.recs[i][0:1] = b"*"
|
||||
ct.dirty = True
|
||||
log.append(
|
||||
{
|
||||
"table": "COSTN.DBF",
|
||||
"recno": i,
|
||||
"field": "_delete",
|
||||
"old": ct.get(i, "CODE"),
|
||||
"new": "",
|
||||
}
|
||||
)
|
||||
codes = {ct.get(i, "CODE") for i in range(n0) if not ct.deleted(i)}
|
||||
for u in struct:
|
||||
code = _txt(u.get("CODE"))
|
||||
if not code:
|
||||
continue
|
||||
r = u.get("_recno")
|
||||
if not u.get("_new") and r is not None and 0 <= int(r) < n0:
|
||||
i = int(r)
|
||||
if i in dele:
|
||||
continue
|
||||
else:
|
||||
if code in codes:
|
||||
raise ValueError(f"COSTN 에 이미 있는 코드: {code}")
|
||||
src = u.get("_from")
|
||||
tmpl = int(src) if src is not None and 0 <= int(src) < n0 else None
|
||||
i = ct.insert(len(ct), {}, template=tmpl)
|
||||
if "IFORM" in ct.fields: # 산출근거 메모는 새 자리로(복사원 메모 덮어쓰기 방지)
|
||||
ct.set_raw(i, "IFORM", b" " * ct.fields["IFORM"].len)
|
||||
log.append(
|
||||
{"table": "COSTN.DBF", "recno": i, "field": "_insert", "old": "", "new": code}
|
||||
)
|
||||
if tmpl is None:
|
||||
for k in ("NAME", "SIZE", "UNIT"):
|
||||
_put(ct, i, k, u.get(k, ""), log, "COSTN.DBF")
|
||||
_put(ct, i, "CODE", code, log, "COSTN.DBF")
|
||||
codes.add(code)
|
||||
for k, v in (
|
||||
u.get("_vals") or {}
|
||||
).items(): # 다른 프로젝트에서 가져온 새 행 — 원래 칸 값 그대로
|
||||
if k in ct.fields and k not in ("CODE", "IFORM") and not k.endswith("_HEX"):
|
||||
try:
|
||||
_put(ct, i, k, v, log, "COSTN.DBF")
|
||||
except ValueError: # 칸 폭·꼴이 다른 값은 건너뜀(원래 틀 값 유지)
|
||||
continue
|
||||
for k in EXTRA_COL:
|
||||
if k in u:
|
||||
_put(ct, i, k, u[k], log, "COSTN.DBF")
|
||||
if ct.dirty:
|
||||
files["COSTN.DBF"] = ct.to_bytes()
|
||||
|
||||
|
||||
def _boq_codes(files: dict, req: dict, log: list) -> None:
|
||||
by: dict[str, list] = {}
|
||||
for u in req.get("boq_code_rows") or []:
|
||||
by.setdefault(str(u.get("table", "")).upper(), []).append(u)
|
||||
for name, ups in by.items():
|
||||
if name not in files:
|
||||
continue
|
||||
bt = DbfTable(files[name])
|
||||
for u in ups:
|
||||
i = int(u.get("_recno", -1))
|
||||
if not (0 <= i < len(bt)):
|
||||
continue
|
||||
for k in ("BDCODE1", "BDCODE2", "BDCODE3", "BDCODE4"):
|
||||
if k in u:
|
||||
_put(bt, i, k, u[k], log, name)
|
||||
if bt.dirty:
|
||||
files[name] = bt.to_bytes()
|
||||
|
||||
|
||||
def apply_struct(files: dict[str, bytes], req: dict) -> tuple[dict[str, bytes], list]:
|
||||
"""구조 손질만 — 새 files 와 바뀐 목록(코드 칸·끼움·지움)."""
|
||||
out = dict(files)
|
||||
log: list = []
|
||||
_costn(out, req, log)
|
||||
_boq_codes(out, req, log)
|
||||
return out, log
|
||||
|
||||
|
||||
class UiSaveReq(BaseModel):
|
||||
"""/api/save 요청 그대로(덧붙은 키는 apply_save 로 넘김) + 구조 덧붙임 키."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
source_path: str
|
||||
target_folder_name: str = "stmate_edited"
|
||||
costn_struct: list[dict] = []
|
||||
costn_deleted: list[int] = []
|
||||
boq_code_rows: list[dict] = []
|
||||
|
||||
|
||||
def _server():
|
||||
import server # 실행 중 바뀔 수 있는 작업 폴더·원천 열기(시험은 임시 폴더로 바꿈)
|
||||
|
||||
return server
|
||||
|
||||
|
||||
@router.post("/api/ui/save")
|
||||
def ui_save(req: UiSaveReq):
|
||||
"""구조 손질 → 바뀐 칸 저장 → work_dbf/<이름>/ · <이름>.STC (원본은 손대지 않음)."""
|
||||
srv = _server()
|
||||
src = srv.open_source(req.source_path)
|
||||
body = req.model_dump()
|
||||
try:
|
||||
files, struct_log = apply_struct(src.files, body)
|
||||
files, changes = apply_save(files, body)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
res = write_output(
|
||||
srv.WORK_DBF_ROOT, safe_name(req.target_folder_name), files, src.names, src.entries
|
||||
)
|
||||
res["changed_files"] = sorted(k for k in files if files[k] is not src.files.get(k))
|
||||
allc = struct_log + changes
|
||||
res["changes"] = allc[:500]
|
||||
res["change_count"] = len(allc)
|
||||
return res
|
||||
|
||||
|
||||
# ── 프로젝트 DATA 검색(wSrch_PL) — 등록된 모든 프로젝트의 단가·내역에서 명칭·규격 찾기 ──
|
||||
_CACHE: dict[str, tuple[float, dict]] = {}
|
||||
COSTN_COLS = (
|
||||
"CODE",
|
||||
"NAME",
|
||||
"SIZE",
|
||||
"UNIT",
|
||||
"BQCODE",
|
||||
"EQCODE",
|
||||
"TAMT1",
|
||||
"LAMT1",
|
||||
"MAMT1",
|
||||
"SAMT1",
|
||||
)
|
||||
COSTN_COLS += ("JDCODE", "EDT_DATE")
|
||||
BOQ_COLS = ("ITNUM", "NAME1", "SIZE1", "UNIT1", "QTY1", "T_AMT1", "BDCODE1", "BQCODE")
|
||||
|
||||
|
||||
def _project_rows(path: str) -> dict:
|
||||
p = pathlib.Path(path)
|
||||
key = str(p)
|
||||
mt = p.stat().st_mtime if p.exists() else 0.0
|
||||
hit = _CACHE.get(key)
|
||||
if hit and hit[0] == mt:
|
||||
return hit[1]
|
||||
files = _server().open_source(path).files
|
||||
costn = parse_dbf_bytes(files["COSTN.DBF"])[1] if "COSTN.DBF" in files else []
|
||||
boq_name = pick_boq_table(files)
|
||||
boq = parse_dbf_bytes(files[boq_name])[1] if boq_name else []
|
||||
data = {
|
||||
"costn": [{k: _txt(r.get(k)) for k in COSTN_COLS} for r in costn if _txt(r.get("CODE"))],
|
||||
"boq": [dict({k: _txt(r.get(k)) for k in BOQ_COLS}, table=boq_name) for r in boq],
|
||||
}
|
||||
_CACHE[key] = (mt, data)
|
||||
return data
|
||||
|
||||
|
||||
SRCH_KINDS = {"LMS": "LMS", "XBD": "XBD", "W": "W", "BOQ": ""}
|
||||
|
||||
|
||||
class SearchReq(BaseModel):
|
||||
kind: str = "LMS" # LMS · XBD · W · BOQ (wcBx_Type 4택)
|
||||
kw: str = "" # 띄어 쓴 낱말 모두 든 것
|
||||
field: str = "name" # name(명칭·규격) · jdcode(호환규정코드) · code(코드번호)
|
||||
paths: list[str] = [] # 비면 등록된 모든 프로젝트
|
||||
limit: int = 500
|
||||
|
||||
|
||||
def match_rows(rows: list[dict], kind: str, kw: str, field: str) -> list[dict]:
|
||||
words = [w.lower() for w in kw.split() if w]
|
||||
pre = SRCH_KINDS.get(kind, "")
|
||||
out = []
|
||||
for r in rows:
|
||||
if kind == "BOQ":
|
||||
if not r.get("BDCODE1") or len(r["BDCODE1"]) < 3:
|
||||
continue
|
||||
hay = f"{r['NAME1']} {r['SIZE1']}"
|
||||
else:
|
||||
if r["CODE"][:1] not in pre:
|
||||
continue
|
||||
hay = {"jdcode": r["JDCODE"], "code": r["CODE"]}.get(field, f"{r['NAME']} {r['SIZE']}")
|
||||
if all(w in hay.lower() for w in words):
|
||||
out.append(r)
|
||||
return out
|
||||
|
||||
|
||||
@router.post("/api/ui/search")
|
||||
def ui_search(req: SearchReq):
|
||||
srv = _server()
|
||||
projs = srv.discover_sources()["projects"]
|
||||
if req.paths:
|
||||
want = set(req.paths)
|
||||
projs = [p for p in projs if p["path"] in want]
|
||||
found, errs = [], []
|
||||
for p in projs:
|
||||
try:
|
||||
data = _project_rows(p["path"])
|
||||
except Exception as e: # 열 수 없는 원천은 건너뛰고 알림
|
||||
errs.append({"project": p["name"], "error": str(e)[:120]})
|
||||
continue
|
||||
rows = data["boq"] if req.kind == "BOQ" else data["costn"]
|
||||
for r in match_rows(rows, req.kind, req.kw, req.field):
|
||||
found.append(dict(r, project=p["name"], path=p["path"]))
|
||||
if len(found) >= req.limit:
|
||||
return {"rows": found, "truncated": True, "errors": errs}
|
||||
return {"rows": found, "truncated": False, "errors": errs}
|
||||
|
||||
|
||||
# ── 다른 내역서 열기(wPrjList — 공사 행마다 BOQ{PCODE}.DBF) ──
|
||||
class LoadBoqReq(BaseModel):
|
||||
source_path: str
|
||||
pcode: str
|
||||
|
||||
|
||||
@router.post("/api/ui/load-boq")
|
||||
def ui_load_boq(req: LoadBoqReq):
|
||||
"""불러오기 결과를 고른 공사(PRJLST 행)의 내역 표·제잡비 설정으로 바꿔 돌려줌."""
|
||||
srv = _server()
|
||||
data = srv.api_load(srv.LoadReq(source_path=req.source_path))
|
||||
pc = _txt(req.pcode)
|
||||
name = f"BOQ{pc}.DBF"
|
||||
if name not in data["boq_tables"]:
|
||||
raise HTTPException(status_code=404, detail=f"내역 표 없음: {name}")
|
||||
prj = next((r for r in data["prjlst_all"] if _txt(r.get("PCODE")) == pc), None)
|
||||
if prj is None:
|
||||
raise HTTPException(status_code=404, detail=f"PRJLST 에 PCODE {pc} 공사 없음")
|
||||
files = srv.read_project_files(req.source_path)
|
||||
smt_kv = srv.parse_prjlst_smt(files.get("PRJLST.SMT", b""), prj.get("PRJ_MEMO_HEX", ""))
|
||||
rows = data["boq_tables"][name]
|
||||
sim = srv.convert_dbf_to_sim_state(data["costn_rows"], rows, smt_kv, prj, data["costn_memos"])
|
||||
for k in ("condCodes", "rxFormula"):
|
||||
sim["sysConfig"][k] = data["sim_state"]["sysConfig"].get(k, sim["sysConfig"].get(k))
|
||||
data.update(boq_table_name=name, boq_rows=rows, boq_count=len(rows), prjlst=prj)
|
||||
data.update(prjlst_smt=smt_kv, sim_state=sim)
|
||||
return data
|
||||
@@ -161,10 +161,7 @@
|
||||
const kj = ctx.computed.kanjubComputed,
|
||||
bt = ctx.computed.boqComputed.totals;
|
||||
try {
|
||||
const saved = await postJson(
|
||||
"/api/save",
|
||||
decorateSavePayload(ViewPrjBoq.buildSavePayload(ctx)),
|
||||
);
|
||||
const saved = await postSave(decorateSavePayload(ViewPrjBoq.buildSavePayload(ctx)));
|
||||
renderWorkspace();
|
||||
alert(
|
||||
`[계산완료(Act_Calc_All) 및 DBF/SMT/STC 저장 성공]\n• 5단 연쇄재계산: 중기(X) ${stat.xCount}건 · 산근(D) ${stat.dCount}건 · 일위대가(B) ${stat.bCount}건\n• 폴더: ${saved.saved_dir}\n• 아카이브: ${saved.saved_stc} (${fmt(saved.stc_size)}B)\n• 직공비: ${fmt(bt.directT)}원 / 총공사비: ${fmt(kj.GRAND_TOTAL)}원`,
|
||||
@@ -174,6 +171,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 저장 길 — 코드 관리(지움·코드 바꿈·다른 표 코드)가 있으면 /api/ui/save(구조 덧붙임 · api_ui_code.py) · 아니면 /api/save
|
||||
function postSave(p) {
|
||||
const UC = global.STMATE_UI_CODE;
|
||||
if (UC && UC.needsUiSave(ctx.dbfData))
|
||||
return postJson("/api/ui/save", Object.assign(p, UC.structPayload(ctx.dbfData)));
|
||||
return postJson("/api/save", p);
|
||||
}
|
||||
|
||||
// 저장 묶음 덧붙임 — 초기값(start_ini) · 공사목록 전체 행(prjlst_rows) · 서버 반영은 파일 담당
|
||||
function decorateSavePayload(p) {
|
||||
const d = ctx.dbfData;
|
||||
@@ -437,7 +442,12 @@
|
||||
|
||||
Object.assign(API, { renderWorkspace, recompute, openModal, postJson, downloadBlob });
|
||||
Object.assign(API, { cascadeAndRender, loadProjectFromServer, insertBoqRow });
|
||||
Object.assign(API, { saveAll: saveAllToDbfServer, decorateSavePayload, openMappedModal });
|
||||
Object.assign(API, {
|
||||
saveAll: saveAllToDbfServer,
|
||||
decorateSavePayload,
|
||||
openMappedModal,
|
||||
postSave,
|
||||
});
|
||||
|
||||
async function initStmateApp() {
|
||||
onClick("modal-close-btn", closeModal);
|
||||
|
||||
@@ -358,6 +358,7 @@ const StmateCalc = (() => {
|
||||
govFeeTotal = 0;
|
||||
// 내역 비율 줄(PR) 밑수 — 표시(PRPT) 줄 금액 누적 · PR 줄을 지나면 비움(영월 조달수수료 4줄 대조)
|
||||
let prBase = { M: 0, L: 0, S: 0 };
|
||||
const doneById = {}; // 코드 붙은 % 줄 밑수용 — 앞 줄 금액
|
||||
const rows = (data.boqItems || []).map((row) => {
|
||||
const pr = prRow(row, slot, prBase);
|
||||
if (pr) {
|
||||
@@ -397,9 +398,21 @@ const StmateCalc = (() => {
|
||||
L = roundDown(rawL, 0),
|
||||
S = roundDown(rawS, 0),
|
||||
T = M + L + S;
|
||||
// 코드 붙은 % 줄(조달수수료 M%·T%) = 같은 상위 공종 안 앞 말단 줄 비목 합 × 율 → 제 비목(server pct_row_base)
|
||||
if (row.pctBase) {
|
||||
const k = row.pctComp,
|
||||
base = row.pctBase.reduce((a, id) => a + ((doneById[id] || {})[k] || 0), 0);
|
||||
const own = /^L/.test(row.code) ? "L" : /^S/.test(row.code) ? "S" : "M";
|
||||
((M = 0), (L = 0), (S = 0));
|
||||
const amt = roundDown((base * num((row.prQtys || [])[slot - 1])) / 100, 0);
|
||||
if (own === "L") L = amt;
|
||||
else if (own === "S") S = amt;
|
||||
else M = amt;
|
||||
T = M + L + S;
|
||||
}
|
||||
let optimizDiff = 0,
|
||||
optimizTarget = "";
|
||||
if (chnMode === "total") {
|
||||
if (chnMode === "total" && !row.pctBase) {
|
||||
// 총액우선(CHNNM=0) — REA 15 OPTIMIZ: 총액 절사 후 남는 1원을 소수부 큰 성분부터 하나씩
|
||||
// (동률은 OPTNM 순서 앞쪽) · 산근·일위대가(smt_eval finish)와 같은 규칙
|
||||
const got = optimize({ M: rawM, L: rawL, S: rawS }, rawT, optOrder);
|
||||
@@ -413,6 +426,7 @@ const StmateCalc = (() => {
|
||||
((M = got.M), (L = got.L), (S = got.S), (T = M + L + S));
|
||||
}
|
||||
}
|
||||
doneById[row.id] = { M, L, S, T };
|
||||
const aggM = roundHalfUp(rawM, 0),
|
||||
aggL = roundHalfUp(rawL, 0),
|
||||
aggS = roundHalfUp(rawS, 0);
|
||||
|
||||
@@ -53,6 +53,8 @@
|
||||
<script src="calc.js"></script>
|
||||
<script src="smt_eval.js"></script>
|
||||
<script src="smt_engine.js"></script>
|
||||
<script src="calc_xrate.js"></script>
|
||||
<script src="calc_a2w.js"></script>
|
||||
<script src="view_prj_boq.js"></script>
|
||||
<script src="view_dan_ilwi.js"></script>
|
||||
<script src="view_kanjub_prt.js"></script>
|
||||
@@ -63,6 +65,9 @@
|
||||
<script src="ui_boq_tools.js"></script>
|
||||
<script src="ui_sys.js"></script>
|
||||
<script src="ui_dan.js"></script>
|
||||
<script src="ui_code.js"></script>
|
||||
<script src="ui_code_amt.js"></script>
|
||||
<script src="ui_merge.js"></script>
|
||||
<script src="plugins_manifest.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -187,6 +187,11 @@
|
||||
null,
|
||||
["mboq", "주공종 집계표 (wMBoq)"],
|
||||
["qtyList", "수량산출서 (wQtyList)"],
|
||||
null,
|
||||
["openSame", "동일코드 추려보기 (wSameCode)"],
|
||||
["openMemo", "메모 입력 (wInDlgMemo)"],
|
||||
["openSpChar", "특수문자 (mMinMsg_)"],
|
||||
["openUser", "사용자문자 (wM_Edit_USER)"],
|
||||
],
|
||||
],
|
||||
["보관함", [["pocketPut", "선택 행 보관함에 담기"]]],
|
||||
@@ -320,6 +325,8 @@
|
||||
const D = global.STMATE_UI_BOQ_DLG;
|
||||
if (D && D[act])
|
||||
return D[act](ctx, api, { model, after, sel, selIdx, costnByCode, insertAt });
|
||||
const X = global.STMATE_UI_TEXT; // 추려보기·메모·특수문자·사용자문자 — scr_ui_text.js
|
||||
if (X && X[act]) return X[act](ctx, api);
|
||||
};
|
||||
document
|
||||
.querySelectorAll("#boq-form [data-boq-act]")
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
.join("");
|
||||
const LEVELS = (ini) =>
|
||||
[1, 2, 3, 4, 5, 6, 7, 8, 9].map((n) => [n, `${n} 레벨 (${B.dispName(ini, n)})`]);
|
||||
const DOTS = [4, 3, 2, 1, 0].map((d) => [d, `소수 ${d}위값을 적용`]);
|
||||
const KINDS = [
|
||||
["BD", "일위대가/산근전체"],
|
||||
["ALL", "모든단가"],
|
||||
@@ -37,10 +36,31 @@
|
||||
["W", "견적단가"],
|
||||
];
|
||||
|
||||
// 공종 목록(wKongJong) — RdoIndex(코드번호 순/명칭 순/분류번호 순) · 코드 골라 수량과 함께 넣기
|
||||
// 공종 목록(wKongJong) — 대공종(KONGJONG.DBF) · RdoLevel · RdoIndex · RdoFilt · 전송될 항목(대공종명·번호·계표시 · 세부공종번호)
|
||||
// 「대공종 해당목록만」 = 분류번호가 대공종 번호로 시작하는 항목(원본 연결 칸 미확인 — 추정)
|
||||
function kongjong(ctx, api, x) {
|
||||
const st = (ctx.uiState.kongjong = ctx.uiState.kongjong || { kind: "BD", sort: "C", kw: "" });
|
||||
Object.assign(
|
||||
st,
|
||||
Object.assign(
|
||||
{
|
||||
big: "",
|
||||
lv: 3,
|
||||
filt: false,
|
||||
trans: false,
|
||||
ibrd: false,
|
||||
sno: "bq",
|
||||
first: "-",
|
||||
start: 1,
|
||||
left: 0,
|
||||
},
|
||||
st,
|
||||
),
|
||||
);
|
||||
const costn = ctx.dbfData.costn_rows || [];
|
||||
const bigs = (ctx.dbfData.kongjong_rows || []).filter((r) =>
|
||||
String(r.NAME || "").replace(/[\]\s]/g, ""),
|
||||
);
|
||||
const draw = () => {
|
||||
const kw = st.kw.trim().toLowerCase();
|
||||
let list = costn.filter((r) => {
|
||||
@@ -48,6 +68,14 @@
|
||||
p = c[0];
|
||||
if (!c) return false;
|
||||
if (st.kind === "BD" ? !/[BD]/.test(p) : st.kind !== "ALL" && p !== st.kind) return false;
|
||||
if (
|
||||
st.filt &&
|
||||
st.big &&
|
||||
!String(r.BQCODE || "")
|
||||
.trim()
|
||||
.startsWith(st.big)
|
||||
)
|
||||
return false;
|
||||
return !kw || `${c} ${r.NAME || ""} ${r.SIZE || ""}`.toLowerCase().includes(kw);
|
||||
});
|
||||
const key = {
|
||||
@@ -77,6 +105,29 @@
|
||||
st.sort,
|
||||
)}</select>
|
||||
<input id="kj-kw" class="win-input" placeholder="명칭·규격·코드" value="${esc(st.kw)}" /><span id="kj-count"></span></div>
|
||||
<div class="stm-row"><label>대공종</label><select id="kj-big" class="win-select"><option value="">(고르지 않음)</option>${bigs.map((r) => `<option value="${esc(String(r.CODE).trim())}" ${st.big === String(r.CODE).trim() ? "selected" : ""}>${esc(String(r.CODE).trim())} ${esc(r.NAME)}</option>`).join("")}</select>
|
||||
<label><input type="checkbox" id="kj-filt" ${st.filt ? "checked" : ""} /> 대공종 해당목록만</label><label>[대공종 레벨 설정]</label><select id="kj-lv" class="win-select">${opt(
|
||||
[1, 2, 3, 4, 5, 6].map((n) => [
|
||||
n,
|
||||
`${B.dispName(ctx.dbfData.start_ini, n)} (+${"=".repeat(n)})`,
|
||||
]),
|
||||
st.lv,
|
||||
)}</select></div>
|
||||
<div class="stm-row"><label><input type="checkbox" id="kj-trans" ${st.trans ? "checked" : ""} /> 대공종명,번호,계표시 전송</label><label><input type="checkbox" id="kj-ibrd" ${st.ibrd ? "checked" : ""} /> 세부공종번호 전송</label>
|
||||
<select id="kj-sno" class="win-select">${opt(
|
||||
[
|
||||
["bq", "분류번호로 전송"],
|
||||
["seq", "일련번호로 전송"],
|
||||
],
|
||||
st.sno,
|
||||
)}</select><label>일련번호 첫자</label><select id="kj-first" class="win-select">${opt(
|
||||
[
|
||||
["-", "- 입력"],
|
||||
["", "없슴"],
|
||||
],
|
||||
st.first,
|
||||
)}</select>
|
||||
<label>시작번호</label><input id="kj-start" type="number" class="win-input" value="${st.start}" style="width:50px;" /><label>좌측여백</label><input id="kj-left" type="number" class="win-input" value="${st.left}" style="width:40px;" /></div>
|
||||
<div class="grid-scroll" style="max-height:340px;"><table class="dbgrid"><thead><tr><th></th><th>코드</th><th>명칭</th><th>규격</th><th>단위</th><th>분류번호</th></tr></thead><tbody id="kj-list"></tbody></table></div>
|
||||
<div class="stm-row"><label>수량</label><input id="kj-qty" class="win-input" type="number" value="0" style="width:90px;" />
|
||||
<span class="stm-note">넣을 곳: ${ctx.uiState.boqGrpTo === "pocket" ? `열린 보관함 (${ctx.uiState.activePocket || 1})` : "내역서 선택 행 앞"}</span>
|
||||
@@ -86,6 +137,15 @@
|
||||
$("kj-kind").onchange = (e) => ((st.kind = e.target.value), draw());
|
||||
$("kj-sort").onchange = (e) => ((st.sort = e.target.value), draw());
|
||||
$("kj-kw").oninput = (e) => ((st.kw = e.target.value), draw());
|
||||
$("kj-big").onchange = (e) => ((st.big = e.target.value), draw());
|
||||
$("kj-filt").onchange = (e) => ((st.filt = e.target.checked), draw());
|
||||
$("kj-lv").onchange = (e) => (st.lv = Number(e.target.value));
|
||||
$("kj-trans").onchange = (e) => (st.trans = e.target.checked);
|
||||
$("kj-ibrd").onchange = (e) => (st.ibrd = e.target.checked);
|
||||
$("kj-sno").onchange = (e) => (st.sno = e.target.value);
|
||||
$("kj-first").onchange = (e) => (st.first = e.target.value);
|
||||
$("kj-start").onchange = (e) => (st.start = Number(e.target.value) || 1);
|
||||
$("kj-left").onchange = (e) => (st.left = Number(e.target.value) || 0);
|
||||
$("kj-ok").onclick = () => {
|
||||
const codes = [...document.querySelectorAll(".kj-pick:checked")].map((c) => c.value);
|
||||
if (!codes.length) return alert("넣을 코드를 고르세요");
|
||||
@@ -93,6 +153,7 @@
|
||||
cbc = x.costnByCode(ctx),
|
||||
qty = B.num(val("kj-qty"));
|
||||
let rec = B.nextRecno(m);
|
||||
const rows0 = [];
|
||||
const rows = codes.map((c) => {
|
||||
const cr = cbc[c];
|
||||
const r = B.makeRow(m, {
|
||||
@@ -103,17 +164,46 @@
|
||||
QTY1: String(qty),
|
||||
});
|
||||
r._recno = rec--;
|
||||
if (st.ibrd)
|
||||
r.ITNUM =
|
||||
" ".repeat(st.left) +
|
||||
(st.sno === "bq"
|
||||
? String(cr.BQCODE || "").trim()
|
||||
: `${st.first}${st.start + rows0.length}`);
|
||||
rows0.push(r);
|
||||
return r;
|
||||
});
|
||||
const big = bigs.find((b) => String(b.CODE).trim() === st.big);
|
||||
let pairs;
|
||||
if (st.trans && big) {
|
||||
const head = B.makeRow(m, {
|
||||
BDCODE1: `T${st.lv}`,
|
||||
NAME1: String(big.NAME || "").trim(),
|
||||
ITNUM: String(big.CODE).trim(),
|
||||
});
|
||||
head._recno = rec--;
|
||||
const tail = B.makeRow(m, {
|
||||
BDCODE1: `E${st.lv}`,
|
||||
ITNUM: B.dispName(ctx.dbfData.start_ini, st.lv),
|
||||
});
|
||||
tail._recno = rec--;
|
||||
rows.unshift(head);
|
||||
rows.push(tail);
|
||||
pairs = [[head, tail]];
|
||||
}
|
||||
api.closeModal();
|
||||
if (ctx.uiState.boqGrpTo === "pocket") {
|
||||
const u = ctx.uiState,
|
||||
n = u.activePocket || 1;
|
||||
u.boqPockets = u.boqPockets || { 1: [], 2: [], 3: [] };
|
||||
u.boqPockets[n].push({ title: rows[0].NAME1, rows, pairs: [] });
|
||||
u.boqPockets[n].push({
|
||||
title: rows[0].NAME1,
|
||||
rows,
|
||||
pairs: pairs ? [[0, rows.length - 1]] : [],
|
||||
});
|
||||
return api.renderWorkspace();
|
||||
}
|
||||
x.after(ctx, api, B.insertRows(m, x.insertAt(ctx), rows));
|
||||
x.after(ctx, api, B.insertRows(m, x.insertAt(ctx), rows, pairs));
|
||||
};
|
||||
}
|
||||
|
||||
@@ -307,9 +397,56 @@
|
||||
[0, 1, 2, 3, 4].map((d) => [d, `${d} 소수 ${d}위값을 적용`]),
|
||||
2,
|
||||
)}</select></div>
|
||||
<p class="stm-warn">비목별 금액 보정(wcBx_Pnt1_M·_L·_X·_W, rd_Amt1·rd_Amt2)은 내역 행 단가 고정 칸이 계산 엔진에 없어 대기</p>
|
||||
<div class="stm-row"><button class="win-btn primary" id="mr-ok">수량 보정</button></div></div>`,
|
||||
<div class="stm-row"><button class="win-btn primary" id="mr-ok">수량 보정</button></div>
|
||||
<strong>비목별 단가 끝전(wcBx_Pnt1_M·_L … · rd_Amt1)</strong>
|
||||
<div class="stm-row"><label>단가 범위</label><select id="mr-arange" class="win-select">${opt(
|
||||
[
|
||||
["all", "내역서 전체"],
|
||||
["sel", "선택된 항목만"],
|
||||
],
|
||||
"all",
|
||||
)}</select></div>
|
||||
${[
|
||||
["M", "재료비"],
|
||||
["L", "노무비"],
|
||||
["S", "경비"],
|
||||
]
|
||||
.map(
|
||||
([k, t]) =>
|
||||
`<div class="stm-row"><label><input type="checkbox" id="mr-on-${k}" checked /> ${t}</label><select id="mr-m-${k}" class="win-select">${opt(
|
||||
[
|
||||
["trunc", "0 절사하여"],
|
||||
["round", "1 반올림 하여"],
|
||||
],
|
||||
"trunc",
|
||||
)}</select><select id="mr-d-${k}" class="win-select">${opt(
|
||||
[0, 1, 2, 3, 4].map((d) => [d, `${d} 소수 ${d}위값을 적용`]),
|
||||
0,
|
||||
)}</select></div>`,
|
||||
)
|
||||
.join("")}
|
||||
<div class="stm-row"><button class="win-btn primary" id="mr-aok">단가 보정</button></div></div>`,
|
||||
);
|
||||
$("mr-aok").onclick = () => {
|
||||
const recs = val("mr-arange") === "sel" ? x.sel(ctx) : null;
|
||||
if (recs && !recs.length) return alert("선택된 항목 없음");
|
||||
const units = {};
|
||||
(ctx.computed.boqComputed.rows || []).forEach(
|
||||
(r) =>
|
||||
r._recno !== undefined &&
|
||||
(units[Number(r._recno)] = { M: r.mUnit, L: r.lUnit, S: r.sUnit }),
|
||||
);
|
||||
const per = {};
|
||||
["M", "L", "S"].forEach(
|
||||
(k) =>
|
||||
$(`mr-on-${k}`).checked &&
|
||||
(per[k] = { mode: val(`mr-m-${k}`), digits: Number(val(`mr-d-${k}`)) }),
|
||||
);
|
||||
const res = T.roundUnits(x.model(ctx), recs, units, per);
|
||||
api.closeModal();
|
||||
ctx.uiState.boqMsg = `단가 ${res.changed}행 보정`;
|
||||
x.after(ctx, api, { ok: true }, true);
|
||||
};
|
||||
$("mr-ok").onclick = () => {
|
||||
const recs = val("mr-range") === "sel" ? x.sel(ctx) : null;
|
||||
if (recs && !recs.length) return alert("선택된 항목 없음");
|
||||
@@ -320,36 +457,80 @@
|
||||
};
|
||||
}
|
||||
|
||||
// 일위대가표 풀기(wExpnd) — RGrpToT · CMBoxUpDn · CMBoxDot
|
||||
// 일위대가표 풀기 — wM_Expnd10(계표시·할증·손료 추가입력) + wM_Expnd(비목별 끝전 M·L·X·W)
|
||||
function expnd(ctx, api, x) {
|
||||
const r = ctx.dbfData.boq_rows.find((q) => Number(q._recno) === x.sel(ctx)[0]);
|
||||
if (!r || B.codeOf(r)[0] !== "B") return alert("풀 일위대가(B 코드) 행 하나를 고르세요");
|
||||
const UPDN = [
|
||||
["trunc", "0 절사하여"],
|
||||
["round", "1 반올림 하여"],
|
||||
];
|
||||
const DOT5 = [0, 1, 2, 3, 4].map((d) => [d, `${d} 소수 ${d}위값을 적용`]);
|
||||
const PIN = [
|
||||
[1, "재료비의"],
|
||||
[2, "노무비의"],
|
||||
[3, "경비의"],
|
||||
[4, "전체의"],
|
||||
];
|
||||
const POUT = [
|
||||
[1, "재료비로 계산"],
|
||||
[2, "노무비로 계산"],
|
||||
[3, "경비로 계산"],
|
||||
[4, "해당비목 계산"],
|
||||
];
|
||||
const per = [
|
||||
["M", "재료비코드(M)"],
|
||||
["L", "노무비코드(L)"],
|
||||
["X", "중장비코드(X)"],
|
||||
["W", "기 타 코드(W)"],
|
||||
]
|
||||
.map(
|
||||
([k, t]) =>
|
||||
`<div class="stm-row"><label style="width:120px;">${t}</label><select id="ex-m-${k}" class="win-select">${opt(UPDN, "trunc")}</select><select id="ex-d-${k}" class="win-select">${opt(DOT5, 4)}</select></div>`,
|
||||
)
|
||||
.join("");
|
||||
const add = (n) =>
|
||||
`<div class="stm-row"><label><input type="checkbox" id="ex-a${n}" /> 항목 추가입력</label><input id="ex-an${n}" class="win-input" value="${n === 1 ? "공구손료" : "잡재료"}" style="width:90px;" /><select id="ex-ai${n}" class="win-select">${opt(PIN, 2)}</select><input id="ex-ap${n}" type="number" class="win-input" value="${n === 1 ? 3 : 2}" style="width:60px;" /> % 를<select id="ex-ao${n}" class="win-select">${opt(POUT, n === 1 ? 3 : 1)}</select></div>`;
|
||||
api.openModal(
|
||||
"일위대가표 풀기 (wExpnd)",
|
||||
"일위대가표 풀기 (wM_Expnd10 · wM_Expnd)",
|
||||
`<div class="stm-form"><p>${esc(B.codeOf(r))} ${esc(r.NAME1)} ${esc(r.SIZE1)} — 수량 ${B.num(r.QTY1)} ${esc(r.UNIT1)}</p>
|
||||
<div class="stm-row"><label>집계(RGrpToT)</label><select id="ex-lv" class="win-select">${opt(
|
||||
<p class="stm-note">내역서에 일위대가표 내용을 풀어내어 입력하고 현 일위대가표는 내역서에서 삭제 됩니다.</p>
|
||||
<div class="stm-row"><label>1. 공종레벨 지정</label><select id="ex-lv" class="win-select">${opt(
|
||||
[
|
||||
[0, "입력안함"],
|
||||
[1, "소계(=)"],
|
||||
[2, "계(==)"],
|
||||
[3, "합계(===)"],
|
||||
],
|
||||
1,
|
||||
)}</select></div>
|
||||
<div class="stm-row"><label>수량 끝전</label><select id="ex-mode" class="win-select">${opt(
|
||||
[
|
||||
["trunc", "절삭하여"],
|
||||
["round", "반올림하여"],
|
||||
],
|
||||
"trunc",
|
||||
)}</select><select id="ex-dot" class="win-select">${opt(DOTS, 4)}</select></div>
|
||||
<p class="stm-note">구성 수량 = 구성표(BDQTY) 수량 × 내역 수량 · 비율(PR)·%단위·구분 줄은 풀지 않음</p>
|
||||
<div class="stm-row"><button class="win-btn primary" id="ex-ok">풀기</button></div></div>`,
|
||||
)}</select><label><input type="checkbox" id="ex-name" checked /> 공종의 명칭/규격 입력</label></div>
|
||||
<div class="stm-row"><label>2. 일위대가표 기본수량 x 할 증 x 내역물량 — 노무비 :</label><input id="ex-hl" type="number" class="win-input" value="1" style="width:60px;" /><label>재료비 :</label><input id="ex-hm" type="number" class="win-input" value="1" style="width:60px;" /></div>
|
||||
<strong>수량 처리방법(비목별 끝전)</strong>${per}
|
||||
<strong>3. 손료계산 추가입력</strong>${add(1)}${add(2)}
|
||||
<p class="stm-note">비율(PR)·%단위·구분 구성 줄은 풀지 않음 · 추가 줄 = 풀린 줄 합의 % (내역 비율 줄 PR)</p>
|
||||
<div class="stm-row"><button class="win-btn primary" id="ex-ok">풀어넣기(O)</button></div></div>`,
|
||||
);
|
||||
$("ex-ok").onclick = () => {
|
||||
const perOpt = Object.fromEntries(
|
||||
["M", "L", "X", "W"].map((k) => [
|
||||
k,
|
||||
{ mode: val(`ex-m-${k}`), digits: Number(val(`ex-d-${k}`)) },
|
||||
]),
|
||||
);
|
||||
const adds = [1, 2]
|
||||
.filter((n) => $(`ex-a${n}`).checked)
|
||||
.map((n) => ({
|
||||
name: val(`ex-an${n}`),
|
||||
prin: Number(val(`ex-ai${n}`)),
|
||||
pct: Number(val(`ex-ap${n}`)),
|
||||
prout: Number(val(`ex-ao${n}`)),
|
||||
}));
|
||||
const o = {
|
||||
level: Number(val("ex-lv")),
|
||||
mode: val("ex-mode"),
|
||||
digits: Number(val("ex-dot")),
|
||||
keepName: $("ex-name").checked,
|
||||
hal: { L: Number(val("ex-hl")) || 1, M: Number(val("ex-hm")) || 1 },
|
||||
per: perOpt,
|
||||
adds,
|
||||
};
|
||||
const res = T.expandIlwi(
|
||||
x.model(ctx),
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* STMATE w 8.0 — 코드 관리 창 끼워 넣기 (scr_ui_code.js) · 코드변경 · 항목교체 · 코드삭제 · +α · 신규등록
|
||||
* 근거: DFM 원문 — uM_ChgCode(현재코드 · 바뀔코드 · * 사용 · 코드변환실행 · 코드일괄변환 · 현재이후 자동입력 · 바뀔코드 초기화)
|
||||
* · uM_ReplCode(대상코드 · 항목교체실행 · 동일항목(명칭, 규격... 단가) 대상코드 자동입력) · uM_DelCode(선 택 · 선택코드
|
||||
* 삭제실행 · 코드일괄삭제(중기비제외/포함) · 사용안된 "천원" "인" 일위대가 전체선택 · 동일항목 자동선택)
|
||||
* · uM_Chg_Plus(종류별 현재최종 + α) · wM_Boxa Act_CopyNew(신규등록) · Act_DEL_CODE · Act_CH_CODE · Act_RP_CODE
|
||||
* 계산 = ui_code.js · 저장 = app.js postSave(/api/ui/save · api_ui_code.py)
|
||||
*/
|
||||
(function (global) {
|
||||
"use strict";
|
||||
const P = (global.STMATE_PLUGINS = global.STMATE_PLUGINS || {
|
||||
screens: {},
|
||||
actions: {},
|
||||
menus: [],
|
||||
});
|
||||
const UC = global.STMATE_UI_CODE;
|
||||
const esc = (s) =>
|
||||
String(s == null ? "" : s).replace(
|
||||
/[&<>"]/g,
|
||||
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c],
|
||||
);
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const KINDS = ["L", "M", "S", "B", "X", "D", "W"];
|
||||
const env = (ctx) => ({
|
||||
d: ctx.dbfData,
|
||||
state: ctx.state,
|
||||
memo: ctx.uiState.editedSmtMap || (ctx.uiState.editedSmtMap = {}),
|
||||
});
|
||||
const need = (ctx) => (ctx.dbfData ? true : (alert("열린 프로젝트가 없음"), false));
|
||||
const done = (ctx, api, msg) => {
|
||||
ctx.uiState.danMsg = msg;
|
||||
api.closeModal();
|
||||
api.cascadeAndRender();
|
||||
};
|
||||
const fail = (res) => alert((res.errors || [res.error || "실패"]).slice(0, 12).join("\n"));
|
||||
|
||||
const TITLE = {
|
||||
change: "코드변경 [uM_ChgCode] — 현재코드 → 바뀔코드",
|
||||
replace: "항목교체(내역포함) [uM_ReplCode] — 현재코드 → 대상코드",
|
||||
delete: "코드삭제 [uM_DelCode]",
|
||||
};
|
||||
|
||||
function st(ctx, mode) {
|
||||
const u = ctx.uiState;
|
||||
if (!u.codeMgr || u.codeMgr.mode !== mode)
|
||||
u.codeMgr = { mode, kind: "L", map: {}, sel: {}, kw: "" };
|
||||
return u.codeMgr;
|
||||
}
|
||||
|
||||
function priceOf(r, k) {
|
||||
const f = { L: "LAMT1", M: "MAMT1", S: "SAMT1" }[k] || "TAMT1";
|
||||
return Number(r[f]) || 0;
|
||||
}
|
||||
|
||||
function mgrHtml(ctx, api) {
|
||||
const s = ctx.uiState.codeMgr,
|
||||
e = env(ctx),
|
||||
used = UC.usedSet(e);
|
||||
const rows = (ctx.dbfData.costn_rows || []).filter((r) => {
|
||||
const c = UC.codeOf(r);
|
||||
if (c[0] !== s.kind) return false;
|
||||
const kw = String(s.kw || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return (
|
||||
!kw || `${c} ${r.NAME || ""} ${r.SIZE || ""} ${r.BQCODE || ""}`.toLowerCase().includes(kw)
|
||||
);
|
||||
});
|
||||
const cnt = (k) => (ctx.dbfData.costn_rows || []).filter((r) => UC.codeOf(r)[0] === k).length;
|
||||
const tabs = KINDS.map(
|
||||
(k) =>
|
||||
`<button class="win-btn cm-kind${s.kind === k ? " primary" : ""}" data-k="${k}">${UC.KIND_NAME[k]}(${cnt(k)})</button>`,
|
||||
).join("");
|
||||
const col = s.mode === "delete" ? "선 택" : s.mode === "replace" ? "대상코드" : "바뀔코드";
|
||||
const body = rows
|
||||
.map((r) => {
|
||||
const c = UC.codeOf(r);
|
||||
const cell =
|
||||
s.mode === "delete"
|
||||
? `<input type="checkbox" class="cm-sel" data-c="${c}" ${s.sel[c] ? "checked" : ""} />`
|
||||
: `<input type="checkbox" class="cm-sel" data-c="${c}" ${s.sel[c] ? "checked" : ""} /> <input class="cell-input cm-map" data-c="${c}" value="${esc(s.map[c] || "")}" style="width:70px;" />`;
|
||||
return `<tr><td class="center"><strong>${c}</strong></td><td class="center">${cell}</td><td>${esc(r.NAME)}</td><td>${esc(r.SIZE)}</td><td class="center">${esc(r.UNIT)}</td><td class="center">${esc(r.BQCODE || "")}</td><td class="center">${esc(r.EQCODE || "")}</td><td class="num">${api.fmt(priceOf(r, s.kind))}</td><td class="center">${used.has(c) ? "*" : ""}</td></tr>`;
|
||||
})
|
||||
.join("");
|
||||
const btn = (id, t) => `<button class="win-btn" id="${id}">${t}</button>`;
|
||||
const acts = {
|
||||
change: [
|
||||
btn("cm-run-sel", "코드변환실행(선택)"),
|
||||
btn("cm-run-all", "코드일괄변환"),
|
||||
btn("cm-auto", "현재이후 자동입력"),
|
||||
btn("cm-clear", "바뀔코드 초기화"),
|
||||
btn("cm-plus", "현재코드 + α"),
|
||||
],
|
||||
replace: [
|
||||
btn("cm-run-all", "항목교체실행"),
|
||||
btn("cm-same", "동일항목 대상코드 자동입력"),
|
||||
btn("cm-clear", "대상코드 초기화"),
|
||||
],
|
||||
delete: [
|
||||
btn("cm-del", "선택코드 삭제실행"),
|
||||
btn("cm-del-x", "코드일괄삭제 (중기비제외)"),
|
||||
btn("cm-del-t", "코드일괄삭제 (중기비포함)"),
|
||||
btn("cm-1000", '사용안된 "천원" "인" 일위대가 선택'),
|
||||
btn("cm-same", "동일항목 자동선택"),
|
||||
btn("cm-clear", "선택코드 초기화"),
|
||||
],
|
||||
}[s.mode];
|
||||
return `<div class="stm-form"><div class="stm-row">${tabs}</div>
|
||||
<div class="stm-row"><input id="cm-kw" class="win-input" value="${esc(s.kw)}" placeholder="코드,명칭,분류번호" style="width:180px;" />${acts.join("")}</div>
|
||||
<div class="grid-scroll" style="max-height:52vh;"><table class="dbgrid"><thead><tr><th>현재코드</th><th>${col}</th><th>명칭</th><th>규격</th><th>단위</th><th>분류번호</th><th>시설코드</th><th>단가</th><th>*</th></tr></thead><tbody>${body}</tbody></table></div>
|
||||
<p class="stm-note">* = 내역서·구성표·산출근거 식에 쓰임 · 쓰이는 코드는 지우지 않음 · 바꾼 뒤 [계산 완료] 저장 때 작업본에 씀</p></div>`;
|
||||
}
|
||||
|
||||
function openMgr(ctx, api, mode) {
|
||||
if (!need(ctx)) return;
|
||||
const s = st(ctx, mode);
|
||||
api.openModal(TITLE[mode], mgrHtml(ctx, api));
|
||||
const re = () => openMgr(ctx, api, mode);
|
||||
document
|
||||
.querySelectorAll(".cm-kind")
|
||||
.forEach((b) => (b.onclick = () => ((s.kind = b.getAttribute("data-k")), re())));
|
||||
$("cm-kw").onchange = (e) => ((s.kw = e.target.value), re());
|
||||
document
|
||||
.querySelectorAll(".cm-map")
|
||||
.forEach(
|
||||
(i) =>
|
||||
(i.onchange = () => (s.map[i.getAttribute("data-c")] = i.value.trim().toUpperCase())),
|
||||
);
|
||||
document
|
||||
.querySelectorAll(".cm-sel")
|
||||
.forEach((i) => (i.onchange = () => (s.sel[i.getAttribute("data-c")] = i.checked)));
|
||||
const on = (id, fn) => $(id) && ($(id).onclick = fn);
|
||||
const pick = (onlySel) =>
|
||||
Object.fromEntries(Object.entries(s.map).filter(([c, v]) => v && (!onlySel || s.sel[c])));
|
||||
const run = (onlySel) => {
|
||||
const m = pick(onlySel);
|
||||
const res = mode === "replace" ? UC.replaceCodes(env(ctx), m) : UC.changeCodes(env(ctx), m);
|
||||
if (!res.ok) return fail(res);
|
||||
s.map = {};
|
||||
s.sel = {};
|
||||
done(
|
||||
ctx,
|
||||
api,
|
||||
`${mode === "replace" ? "항목교체" : "코드변경"} ${res.count}건 · 내역 ${res.refs.boq} · 구성 ${res.refs.comp} · 식 ${res.refs.memo}곳`,
|
||||
);
|
||||
};
|
||||
on("cm-run-sel", () => run(true));
|
||||
on("cm-run-all", () => run(false));
|
||||
on("cm-clear", () => {
|
||||
const k = s.kind;
|
||||
Object.keys(s.map).forEach((c) => c[0] === k && delete s.map[c]);
|
||||
Object.keys(s.sel).forEach((c) => c[0] === k && delete s.sel[c]);
|
||||
re();
|
||||
});
|
||||
on("cm-auto", () => {
|
||||
const list = (ctx.dbfData.costn_rows || []).map(UC.codeOf).filter((c) => c[0] === s.kind);
|
||||
const from = list.find((c) => s.sel[c]) || list[0];
|
||||
const start = prompt(`${from} 부터 차례로 넣을 첫 바뀔코드`, from);
|
||||
if (!start || !UC.CODE_RE.test(start.trim().toUpperCase())) return;
|
||||
let n = UC.codeNum(start.trim().toUpperCase());
|
||||
list.slice(list.indexOf(from)).forEach((c) => (s.map[c] = UC.mkCode(s.kind, n++)));
|
||||
re();
|
||||
});
|
||||
on("cm-plus", () => openPlus(ctx, api));
|
||||
on("cm-same", () => {
|
||||
const m = UC.autoReplaceMap(ctx.dbfData.costn_rows, s.kind);
|
||||
if (mode === "delete") Object.keys(m).forEach((c) => (s.sel[c] = true));
|
||||
else Object.assign(s.map, m);
|
||||
if (!Object.keys(m).length) alert("동일항목 없음");
|
||||
re();
|
||||
});
|
||||
on(
|
||||
"cm-1000",
|
||||
() => (UC.unusedBUnit(env(ctx)).forEach((c) => (s.sel[c] = true)), (s.kind = "B"), re()),
|
||||
);
|
||||
const del = (codes, what) => {
|
||||
if (!codes.length) return alert(`${what} 없음`);
|
||||
if (
|
||||
!confirm(
|
||||
`${what} ${codes.length}건을 지움\n${codes.slice(0, 20).join(", ")}${codes.length > 20 ? " …" : ""}`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
const res = UC.deleteCodes(env(ctx), codes);
|
||||
if (!res.ok) return fail(res);
|
||||
s.sel = {};
|
||||
done(
|
||||
ctx,
|
||||
api,
|
||||
`코드삭제 ${res.count}건${res.kept.length ? ` · 쓰이는 코드 ${res.kept.length}건은 남김` : ""}`,
|
||||
);
|
||||
};
|
||||
on("cm-del", () =>
|
||||
del(
|
||||
Object.keys(s.sel).filter((c) => s.sel[c]),
|
||||
"선택코드",
|
||||
),
|
||||
);
|
||||
on("cm-del-x", () => del(UC.unusedCodes(env(ctx), false), "안 쓰이는 코드(중기비제외)"));
|
||||
on("cm-del-t", () => del(UC.unusedCodes(env(ctx), true), "안 쓰이는 코드(중기비포함)"));
|
||||
}
|
||||
|
||||
// 코드번호 변경(현재코드 + α)
|
||||
function openPlus(ctx, api) {
|
||||
if (!need(ctx)) return;
|
||||
const last = UC.lastCodes(ctx.dbfData.costn_rows);
|
||||
const kinds = ["L", "M", "S", "B", "X", "D", "W"];
|
||||
api.openModal(
|
||||
"코드번호 변경 ( 현재코드 + α ) [uM_Chg_Plus]",
|
||||
`<div class="stm-form">${kinds
|
||||
.map(
|
||||
(k) =>
|
||||
`<div class="stm-row"><label style="width:260px;">${UC.KIND_NAME[k]} ( 현재최종 : ${last[k] || "-"} ) +</label><input id="cp-${k}" type="number" class="win-input" value="0" style="width:90px;" /></div>`,
|
||||
)
|
||||
.join(
|
||||
"",
|
||||
)}<div class="stm-row"><button class="win-btn primary" id="cp-ok">코드번호 변경(O)</button><button class="win-btn" id="cp-no">취 소(C)</button></div></div>`,
|
||||
);
|
||||
$("cp-no").onclick = api.closeModal;
|
||||
$("cp-ok").onclick = () => {
|
||||
const alpha = Object.fromEntries(kinds.map((k) => [k, Number($(`cp-${k}`).value) || 0]));
|
||||
const res = UC.changeCodes(env(ctx), UC.plusMap(ctx.dbfData.costn_rows, alpha));
|
||||
if (!res.ok) return fail(res);
|
||||
done(ctx, api, `코드번호 + α ${res.count}건`);
|
||||
};
|
||||
}
|
||||
|
||||
// ── 단가목록표 한 코드 동작(신규등록 · 현재코드 삭제 · 코드번호 변경 · 항목교체) ──
|
||||
function copyNew(ctx, api, code) {
|
||||
if (!need(ctx)) return;
|
||||
const kind = code
|
||||
? code[0]
|
||||
: (prompt("새 코드 종류 글자(L M S B X D W)", "M") || "").trim().toUpperCase();
|
||||
if (!kind) return;
|
||||
const def = UC.nextCode(ctx.dbfData.costn_rows, kind);
|
||||
const nc = (prompt(code ? `${code} 를 복사한 새 코드` : "새 코드", def) || "")
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
if (!nc) return;
|
||||
const fields = code
|
||||
? {}
|
||||
: {
|
||||
NAME: prompt("명칭", "") || "",
|
||||
SIZE: prompt("규격", "") || "",
|
||||
UNIT: prompt("단위", "") || "",
|
||||
};
|
||||
const res = UC.addCode(env(ctx), { from: code || null, kind, code: nc, fields });
|
||||
if (!res.ok) return fail(res);
|
||||
const s = ctx.uiState.dan || (ctx.uiState.dan = {});
|
||||
Object.assign(s, { code: res.code, kind: "ALL", kw: res.code });
|
||||
ctx.uiState.danMsg = `${res.code} 신규등록${code ? ` (${code} 복사)` : ""}`;
|
||||
api.cascadeAndRender();
|
||||
}
|
||||
function delOne(ctx, api, code) {
|
||||
if (!code || !confirm(`${code} 삭제?`)) return;
|
||||
const res = UC.deleteCodes(env(ctx), [code]);
|
||||
if (!res.ok) return fail(res);
|
||||
ctx.uiState.dan.code = "";
|
||||
ctx.uiState.danMsg = `${code} 삭제`;
|
||||
api.cascadeAndRender();
|
||||
}
|
||||
function chgOne(ctx, api, code, replace) {
|
||||
if (!code) return;
|
||||
const to = (
|
||||
prompt(replace ? `${code} 를 쓰는 자리를 바꿀 대상코드` : `${code} → 바뀔코드`, "") || ""
|
||||
)
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
if (!to) return;
|
||||
const res = replace
|
||||
? UC.replaceCodes(env(ctx), { [code]: to })
|
||||
: UC.changeCodes(env(ctx), { [code]: to });
|
||||
if (!res.ok) return fail(res);
|
||||
ctx.uiState.dan.code = replace ? code : to;
|
||||
ctx.uiState.danMsg = `${code} → ${to} ${replace ? "항목교체" : "코드변경"} · 내역 ${res.refs.boq} · 구성 ${res.refs.comp} · 식 ${res.refs.memo}곳`;
|
||||
api.cascadeAndRender();
|
||||
}
|
||||
|
||||
Object.assign(P.actions, {
|
||||
Act_Chg_Code_Sel: (ctx, api) => openMgr(ctx, api, "change"),
|
||||
Act_Chg_Code_All: (ctx, api) => openMgr(ctx, api, "change"),
|
||||
Act_Chg_Plus: (ctx, api) => openPlus(ctx, api),
|
||||
Act_Del_Code_Sel: (ctx, api) => openMgr(ctx, api, "delete"),
|
||||
Act_Del_Code_All_Minus: (ctx, api) => (
|
||||
openMgr(ctx, api, "delete"),
|
||||
$("cm-del-x") && $("cm-del-x").click()
|
||||
),
|
||||
Act_Del_Code_All_Plus: (ctx, api) => (
|
||||
openMgr(ctx, api, "delete"),
|
||||
$("cm-del-t") && $("cm-del-t").click()
|
||||
),
|
||||
Act_Chg_Item: (ctx, api) => openMgr(ctx, api, "replace"),
|
||||
});
|
||||
global.STMATE_UI_CODE_SCR = { openMgr, openPlus, copyNew, delOne, chgOne, env };
|
||||
})(window);
|
||||
@@ -47,13 +47,20 @@
|
||||
const ini = ctx.dbfData.start_ini || {};
|
||||
const slots = U.slotNames(ini);
|
||||
const sort = s.sort || prefs().dflIndex || "C";
|
||||
const list = U.filterRows(rows, {
|
||||
let list = U.filterRows(rows, {
|
||||
kind: s.kind,
|
||||
kw: s.kw,
|
||||
mode: s.mode,
|
||||
sort,
|
||||
memos: ctx.dbfData.costn_memos || {},
|
||||
});
|
||||
const UA = global.STMATE_UI_CODE_AMT;
|
||||
if (UA && s.pp) list = list.filter((r) => UA.ppMatch(r, s.pp)); // 속성 추려보기(rg_FLT_*_PP)
|
||||
if (s.nm) list = list.filter((r) => String(r.NAME || "").trim() === s.nm); // 명칭 추려보기
|
||||
if (UA && s.called) {
|
||||
const called = UA.calledCodes(ctx.dbfData); // 불러간 목록(wM_Box_H)
|
||||
list = list.filter((r) => called.has(U.codeOf(r)));
|
||||
}
|
||||
const cnt = U.counts(rows);
|
||||
const n2 = prefs().useName2;
|
||||
const nm = (r, k) => (n2 && String(r[`${k}2`] || "").trim() ? r[`${k}2`] : r[k]) || "";
|
||||
@@ -114,6 +121,7 @@
|
||||
<button class="win-btn" id="dan-use" ${s.code ? "" : "disabled"}>사용처 찾기${s.code ? ` (${esc(s.code)})` : ""}</button>
|
||||
<span class="stm-note">${esc(ctx.uiState.danMsg || "")}</span>
|
||||
</div>
|
||||
${toolBar(ctx, s)}
|
||||
<div class="split-vert"><div class="pane-box" style="flex:1;"><div class="grid-scroll" id="dan-grid"><table class="dbgrid"><thead><tr>
|
||||
<th>No</th><th>코드</th><th>명칭</th><th>규격</th><th>단위</th><th>재료비</th><th>노무비</th><th>경비</th><th>${esc(slots.applied)}</th>
|
||||
${slots.list.map((x) => `<th title="${esc(x.name)}">${esc(x.name)}</th>`).join("")}<th>채택</th><th>분류번호</th><th>시설코드</th><th></th>
|
||||
@@ -121,6 +129,20 @@
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// 코드 관리·단가 손질 단추줄 — scr_ui_code.js · scr_ui_dan2.js · 추려보기(속성·명칭·불러간 목록)
|
||||
function toolBar(ctx, s) {
|
||||
const UA = global.STMATE_UI_CODE_AMT;
|
||||
if (!UA || !global.STMATE_UI_CODE_SCR) return "";
|
||||
const pp = s.kind === "B" ? UA.PP_B : s.kind === "M" ? UA.PP_M : [];
|
||||
const names = UA.nameList(ctx.dbfData.costn_rows, s.kind === "ALL" ? "" : s.kind, "NAME");
|
||||
const b = (id, t, on) =>
|
||||
`<button class="win-btn" id="${id}" ${on === false ? "disabled" : ""}>${t}</button>`;
|
||||
return `<div class="win-subbar">${b("dan-new", "신규등록(A)")}${b("dan-del", "현재코드 삭제", Boolean(s.code))}${b("dan-chg", "코드번호 변경", Boolean(s.code))}${b("dan-rp", "현재코드 항목교체(내역포함)", Boolean(s.code))}${b("dan-jd", "호환규정코드 검색")}${b("dan-hal", "적용단가 일괄할증")}${b("dan-chgamt", "단가 일괄변경")}${b("dan-xr", "손료계수(F6)")}
|
||||
${pp.length ? `<select id="dan-pp" class="win-select"><option value="">속성: 모 두 보 임</option>${pp.map((x) => `<option ${s.pp === x ? "selected" : ""}>${esc(x)}</option>`).join("")}</select>` : ""}
|
||||
<select id="dan-nm" class="win-select" style="max-width:180px;"><option value="">명칭 추려보기: 모두보기</option>${names.map(([n, k]) => `<option value="${esc(n)}" ${s.nm === n ? "selected" : ""}>${esc(n)} (${k})</option>`).join("")}</select>
|
||||
<label><input type="checkbox" id="dan-called" ${s.called ? "checked" : ""} /> 불러간 목록만</label></div>`;
|
||||
}
|
||||
|
||||
function boxa(ctx, api) {
|
||||
const slots = U.slotNames(ctx.dbfData.start_ini);
|
||||
const s = st(ctx);
|
||||
@@ -200,6 +222,24 @@
|
||||
$("dan-boxa").onclick = () => boxa(ctx, api);
|
||||
$("dan-edthal").onclick = () => edtHal(ctx, api);
|
||||
$("dan-use").onclick = () => findUse(ctx, api);
|
||||
const C = global.STMATE_UI_CODE_SCR,
|
||||
T2 = global.STMATE_UI_DAN2;
|
||||
const on = (id, fn) => $(id) && ($(id).onclick = fn);
|
||||
if (C) {
|
||||
on("dan-new", () => C.copyNew(ctx, api, s.code));
|
||||
on("dan-del", () => C.delOne(ctx, api, s.code));
|
||||
on("dan-chg", () => C.chgOne(ctx, api, s.code, false));
|
||||
on("dan-rp", () => C.chgOne(ctx, api, s.code, true));
|
||||
}
|
||||
if (T2) {
|
||||
on("dan-jd", () => T2.openJD(ctx, api));
|
||||
on("dan-hal", () => T2.openHal(ctx, api));
|
||||
on("dan-chgamt", () => T2.openChgAmt(ctx, api));
|
||||
on("dan-xr", () => T2.openXRate(ctx, api, s.code));
|
||||
}
|
||||
if ($("dan-pp")) $("dan-pp").onchange = (e) => ((s.pp = e.target.value), rr());
|
||||
if ($("dan-nm")) $("dan-nm").onchange = (e) => ((s.nm = e.target.value), rr());
|
||||
if ($("dan-called")) $("dan-called").onchange = (e) => ((s.called = e.target.checked), rr());
|
||||
const grid = $("dan-grid");
|
||||
if (s.scroll) grid.scrollTop = s.scroll;
|
||||
grid.onscroll = () => (s.scroll = grid.scrollTop);
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
/**
|
||||
* STMATE w 8.0 — 단가 손질 도구 창 끼워 넣기 (scr_ui_dan2.js)
|
||||
* 단가 일괄변경·물가변동(uM_Chg_Amt · Act_1st_2nd/2nd_1st) · 적용단가 일괄할증(Edt_Hal_Rate/Amt) · 프로젝트 계산 초기값(wM_Set_Init)
|
||||
* · 호환규정코드 찾기/입력(wM_JDCode) · 시간당 손료계수·수량계산기(wM_Edit_XRate · F6) · 사용자문자(wM_Edit_USER)
|
||||
* · 실적단가 → 일식단가(wM_Conv_A2W) · 특수문자(mMinMsg_) · 메모(wInDlgMemo) · 동일코드 추려보기(wSameCode)
|
||||
* 계산 = ui_code_amt.js · calc_xrate.js(STMATE_XRATE) · calc_a2w.js(STMATE_A2W) · 코드 등록 = ui_code.js
|
||||
*/
|
||||
(function (global) {
|
||||
"use strict";
|
||||
const P = (global.STMATE_PLUGINS = global.STMATE_PLUGINS || {
|
||||
screens: {},
|
||||
actions: {},
|
||||
menus: [],
|
||||
});
|
||||
const UA = global.STMATE_UI_CODE_AMT;
|
||||
const UC = global.STMATE_UI_CODE;
|
||||
const esc = (s) =>
|
||||
String(s == null ? "" : s).replace(
|
||||
/[&<>"]/g,
|
||||
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c],
|
||||
);
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const val = (id) => ($(id) ? $(id).value : "");
|
||||
const num = (v) => Number(String(v == null ? "" : v).replace(/,/g, "")) || 0;
|
||||
const opt = (list, cur) =>
|
||||
list
|
||||
.map(
|
||||
([v, t]) =>
|
||||
`<option value="${v}" ${String(v) === String(cur) ? "selected" : ""}>${esc(t)}</option>`,
|
||||
)
|
||||
.join("");
|
||||
const need = (ctx) => (ctx.dbfData ? true : (alert("열린 프로젝트가 없음"), false));
|
||||
const selCode = (ctx) => (ctx.uiState.dan || {}).code || "";
|
||||
const KN = (k) => UC.KIND_NAME[k] || k;
|
||||
const DOT = [
|
||||
[-1, "10원 단위"],
|
||||
[0, "1원 단위(소수 0)"],
|
||||
[1, "소수 1자리"],
|
||||
[2, "소수 2자리"],
|
||||
[4, "소수 4자리"],
|
||||
];
|
||||
|
||||
// 계산 원천 맞춤 — 1차 단가 바뀐 코드(기초단가 = 자원 · 그 밖 = 비목 합성 자원)
|
||||
function syncState(ctx, codes) {
|
||||
const rows = new Map((ctx.dbfData.costn_rows || []).map((r) => [UC.codeOf(r), r]));
|
||||
codes.forEach((c) => {
|
||||
const r = rows.get(c);
|
||||
if (!r) return;
|
||||
if (/^[MLS]/.test(c)) {
|
||||
const res = (ctx.state.resources || []).find((x) => x.code === c && !x.isSynthetic);
|
||||
if (res) Object.assign(res, { adoptMode: "manual", manualPrice: num(r.TAMT1) });
|
||||
} else
|
||||
global.STMATE_VIEW_PRJ_BOQ.syncCostnUnitPrice(
|
||||
ctx,
|
||||
c,
|
||||
num(r.MAMT1),
|
||||
num(r.LAMT1),
|
||||
num(r.SAMT1),
|
||||
);
|
||||
});
|
||||
}
|
||||
const finish = (ctx, api, msg) => (
|
||||
(ctx.uiState.danMsg = msg),
|
||||
api.closeModal(),
|
||||
api.cascadeAndRender()
|
||||
);
|
||||
|
||||
// ── 단가 일괄변경·물가변동 ──
|
||||
const SLOT = [
|
||||
[1, "1 (당초·적용)"],
|
||||
[2, "2 (변경)"],
|
||||
[3, "3"],
|
||||
[4, "4"],
|
||||
];
|
||||
function openChgAmt(ctx, api) {
|
||||
if (!need(ctx)) return;
|
||||
const c = selCode(ctx);
|
||||
api.openModal(
|
||||
"단가 일괄변경 · 물가변동 [uM_Chg_Amt]",
|
||||
`<div class="stm-form"><div class="stm-row"><label>대상 비목</label>${["L", "M", "S", "W", "B", "D", "X"].map((k) => `<label><input type="checkbox" class="ca-k" value="${k}" ${"LMS".includes(k) ? "checked" : ""} /> ${KN(k)}</label>`).join("")}</div>
|
||||
<div class="stm-row"><label>차수</label><select id="ca-from" class="win-select">${opt(SLOT, 1)}</select> 단가 ×<input id="ca-rate" type="number" class="win-input" value="100" style="width:80px;" /> % → <select id="ca-to" class="win-select">${opt(SLOT, 1)}</select> 단가</div>
|
||||
<div class="stm-row"><label>끝전</label><select id="ca-dot" class="win-select">${opt(DOT, 0)}</select><select id="ca-up" class="win-select">${opt(
|
||||
[
|
||||
["trunc", "절사"],
|
||||
["round", "반올림"],
|
||||
],
|
||||
"trunc",
|
||||
)}</select><label><input type="checkbox" id="ca-z" checked /> 0원은 적용안함</label>${c ? `<label><input type="checkbox" id="ca-one" /> 선택 코드 ${esc(c)} 만</label>` : ""}</div>
|
||||
<p class="stm-note">비목(재·노·경) 칸마다 따로 곱함 · 낙찰률 행은 2차 칸(100% 단가 자리)을 건드리지 않음 · 대가·산근·중기 1차는 [계산 완료] 때 다시 셈</p>
|
||||
<div class="stm-row"><button class="win-btn primary" id="ca-ok">변경 실행</button></div></div>`,
|
||||
);
|
||||
$("ca-ok").onclick = () => {
|
||||
const kinds = [...document.querySelectorAll(".ca-k:checked")].map((i) => i.value).join("");
|
||||
const ch = UA.chgAmt(ctx.dbfData.costn_rows, {
|
||||
kinds,
|
||||
from: Number(val("ca-from")),
|
||||
to: Number(val("ca-to")),
|
||||
rate: num(val("ca-rate")),
|
||||
digits: Number(val("ca-dot")),
|
||||
mode: val("ca-up"),
|
||||
skipZero: $("ca-z").checked,
|
||||
codes: $("ca-one") && $("ca-one").checked ? [c] : null,
|
||||
});
|
||||
if (Number(val("ca-to")) === 1)
|
||||
syncState(
|
||||
ctx,
|
||||
ch.map((x) => x.code),
|
||||
);
|
||||
finish(
|
||||
ctx,
|
||||
api,
|
||||
`단가 일괄변경 ${ch.length}건 (${val("ca-from")}차 × ${val("ca-rate")}% → ${val("ca-to")}차)`,
|
||||
);
|
||||
};
|
||||
}
|
||||
const copySlot = (from, to) => (ctx, api) => {
|
||||
if (
|
||||
!need(ctx) ||
|
||||
!confirm(`${from === 1 ? "당초단가 ⇒ 변경단가" : "변경단가 ⇒ 당초단가"} 로 모든 단가를 옮김`)
|
||||
)
|
||||
return;
|
||||
const ch = UA.chgAmt(ctx.dbfData.costn_rows, {
|
||||
kinds: "LMSWBDX",
|
||||
from,
|
||||
to,
|
||||
rate: 100,
|
||||
digits: 4,
|
||||
skipZero: false,
|
||||
});
|
||||
if (to === 1)
|
||||
syncState(
|
||||
ctx,
|
||||
ch.map((x) => x.code),
|
||||
);
|
||||
ctx.uiState.danMsg = `${from}차 → ${to}차 단가 ${ch.length}건`;
|
||||
api.cascadeAndRender();
|
||||
};
|
||||
|
||||
// ── 적용단가 일괄할증 ──
|
||||
function openHal(ctx, api) {
|
||||
if (!need(ctx)) return;
|
||||
const c = selCode(ctx);
|
||||
api.openModal(
|
||||
"적용단가 일괄할증 [Edt_Hal_Rate · Edt_Hal_Amt]",
|
||||
`<div class="stm-form"><div class="stm-row"><label>대상</label><select id="hl-k" class="win-select">${opt(
|
||||
[
|
||||
["M", "재료비"],
|
||||
["L", "노무비"],
|
||||
["S", "경비"],
|
||||
["W", "견적단가"],
|
||||
["MLS", "기초단가 전체"],
|
||||
].concat(c ? [["one", `선택 코드 ${c}`]] : []),
|
||||
"M",
|
||||
)}</select></div>
|
||||
<div class="stm-row"><label>할증</label><select id="hl-how" class="win-select">${opt(
|
||||
[
|
||||
["rate", "율(%) 할증"],
|
||||
["amt", "금액(원) 가산"],
|
||||
],
|
||||
"rate",
|
||||
)}</select><input id="hl-v" type="number" class="win-input" value="0" style="width:90px;" /></div>
|
||||
<div class="stm-row"><label>끝전</label><select id="hl-dot" class="win-select">${opt(DOT, 0)}</select><select id="hl-up" class="win-select">${opt(
|
||||
[
|
||||
["trunc", "절사"],
|
||||
["round", "반올림"],
|
||||
],
|
||||
"trunc",
|
||||
)}</select></div>
|
||||
<p class="stm-note">적용단가 = 기준단가(AMT_61, 없으면 지금 적용단가) × (1 + 율) 또는 + 금액 · 0 이면 기준단가로 되돌림</p>
|
||||
<div class="stm-row"><button class="win-btn primary" id="hl-ok">할증 적용</button></div></div>`,
|
||||
);
|
||||
$("hl-ok").onclick = () => {
|
||||
const k = val("hl-k");
|
||||
const ch = UA.halAll(ctx.dbfData.costn_rows, {
|
||||
kinds: k === "one" ? c[0] : k,
|
||||
codes: k === "one" ? [c] : null,
|
||||
how: val("hl-how"),
|
||||
value: num(val("hl-v")),
|
||||
digits: Number(val("hl-dot")),
|
||||
mode: val("hl-up"),
|
||||
});
|
||||
syncState(
|
||||
ctx,
|
||||
ch.map((x) => x.code),
|
||||
);
|
||||
finish(ctx, api, `일괄할증 ${ch.length}건`);
|
||||
};
|
||||
}
|
||||
|
||||
// ── 프로젝트 계산 초기값(분류번호·적용기준일·시설코드·수공분류코드·호환규정코드 초기화) ──
|
||||
const INIT_G = [
|
||||
["BQ", "분류번호"],
|
||||
["ED", "적용기준일"],
|
||||
["EQ", "시설코드"],
|
||||
["WQ", "수공분류코드"],
|
||||
["JD", "호환규정코드"],
|
||||
];
|
||||
function openSetInit(ctx, api) {
|
||||
if (!need(ctx)) return;
|
||||
const box = (g) =>
|
||||
`<fieldset style="display:inline-block;vertical-align:top;margin:4px;"><legend>${g[1]} 초기화</legend>${UA.INIT_KINDS.map((k) => `<label style="display:block;"><input type="checkbox" class="si-c" data-g="${g[0]}" value="${k}" /> ${KN(k)} 초기화</label>`).join("")}<button class="win-btn si-all" data-g="${g[0]}">전체항목 선택 / 취소</button></fieldset>`;
|
||||
api.openModal(
|
||||
"프로젝트 분류번호 / 시설코드 / 호환규정코드 / 수공분류코드 / 적용기준일 초기화 [wM_Set_Init]",
|
||||
`<div class="stm-form">${INIT_G.map(box).join("")}<div class="stm-row"><button class="win-btn primary" id="si-ok">선택항목 초기화(O)</button></div></div>`,
|
||||
);
|
||||
document.querySelectorAll(".si-all").forEach(
|
||||
(b) =>
|
||||
(b.onclick = () => {
|
||||
const cs = [...document.querySelectorAll(`.si-c[data-g="${b.getAttribute("data-g")}"]`)];
|
||||
const on = !cs.every((x) => x.checked);
|
||||
cs.forEach((x) => (x.checked = on));
|
||||
}),
|
||||
);
|
||||
$("si-ok").onclick = () => {
|
||||
const pick = {};
|
||||
document
|
||||
.querySelectorAll(".si-c:checked")
|
||||
.forEach(
|
||||
(x) =>
|
||||
(pick[x.getAttribute("data-g")] = (pick[x.getAttribute("data-g")] || "") + x.value),
|
||||
);
|
||||
if (!Object.keys(pick).length || !confirm("선택한 칸을 비움")) return;
|
||||
const n = UA.initCols(ctx.dbfData.costn_rows, pick);
|
||||
ctx.dbfData.ui_dirty = true;
|
||||
finish(
|
||||
ctx,
|
||||
api,
|
||||
`초기화 — ${
|
||||
Object.entries(n)
|
||||
.map(([k, v]) => `${k} ${v}건`)
|
||||
.join(" · ") || "비울 칸 없음"
|
||||
}`,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
// ── 호환규정코드 찾기 / 입력 ──
|
||||
function openJD(ctx, api) {
|
||||
if (!need(ctx)) return;
|
||||
const c = selCode(ctx);
|
||||
const r = (ctx.dbfData.costn_rows || []).find((x) => UC.codeOf(x) === c);
|
||||
const dup = UA.jdDup(ctx.dbfData.costn_rows);
|
||||
api.openModal(
|
||||
"공사원가 호환규정코드 찾기 / 입력 [wM_JDCode]",
|
||||
`<div class="stm-form">${r ? `<p><strong>${c}</strong> ${esc(r.NAME)} ${esc(r.SIZE)} ${esc(r.UNIT)} — 지금 호환규정코드 <strong>${esc(r.JDCODE || "(없음)")}</strong></p>` : '<p class="stm-warn">단가목록표에서 코드를 먼저 고르세요(중복체크만 가능)</p>'}
|
||||
<div class="stm-row"><label>기준</label><select id="jd-base" class="win-select">${opt(
|
||||
[
|
||||
["N", "명칭"],
|
||||
["NS", "명칭/규격"],
|
||||
["BQ", "분류번호"],
|
||||
["EQ", "시설코드"],
|
||||
],
|
||||
"NS",
|
||||
)}</select><button class="win-btn" id="jd-go" ${r ? "" : "disabled"}>등록된 프로젝트에서 찾기</button>
|
||||
<input id="jd-in" class="win-input" value="${esc((r && r.JDCODE) || "")}" style="width:160px;" placeholder="호환규정코드" /><button class="win-btn primary" id="jd-set" ${r ? "" : "disabled"}>입력</button><button class="win-btn" id="jd-del" ${r ? "" : "disabled"}>호환규정코드 삭제</button></div>
|
||||
<div id="jd-res"></div>
|
||||
<p class="stm-note">조달청 호환규정코드 목록(JDCODE_*.BSC)은 암호 자료라 쓰지 않음 — 등록된 프로젝트의 같은 항목 코드를 후보로 보임</p>
|
||||
<strong>호환규정코드 중복체크 ${dup.length}건</strong><div class="grid-scroll" style="max-height:20vh;"><table class="dbgrid"><tbody>${dup.map((x) => `<tr><td>${esc(x.jd)}</td><td>${x.codes.join(", ")}</td></tr>`).join("")}</tbody></table></div></div>`,
|
||||
);
|
||||
const set = (v) => {
|
||||
r.JDCODE = v;
|
||||
ctx.dbfData.ui_dirty = true;
|
||||
finish(ctx, api, `${c} 호환규정코드 ${v || "삭제"}`);
|
||||
};
|
||||
if (!r) return;
|
||||
$("jd-set").onclick = () => set(val("jd-in").trim());
|
||||
$("jd-del").onclick = () => set("");
|
||||
$("jd-go").onclick = async () => {
|
||||
const kind = /^[LMS]/.test(c) ? "LMS" : c[0] === "W" ? "W" : "XBD";
|
||||
const res = await api.postJson("/api/ui/search", {
|
||||
kind,
|
||||
kw: String(r.NAME || "").trim(),
|
||||
limit: 2000,
|
||||
});
|
||||
const pool = res.rows.concat(
|
||||
(ctx.dbfData.costn_rows || []).map((x) => Object.assign({ project: "(지금 프로젝트)" }, x)),
|
||||
);
|
||||
const cand = UA.jdCandidates(r, pool, val("jd-base"));
|
||||
$("jd-res").innerHTML = cand.length
|
||||
? `<table class="dbgrid"><thead><tr><th>호환규정코드</th><th>건수</th><th>단위</th><th>프로젝트</th><th></th></tr></thead><tbody>${cand.map((x) => `<tr><td>${esc(x.jd)}</td><td class="num">${x.n}</td><td>${esc(x.unit)}</td><td>${esc(x.src.slice(0, 3).join(" · "))}</td><td><button class="win-btn jd-pick" data-jd="${esc(x.jd)}">고름</button></td></tr>`).join("")}</tbody></table>`
|
||||
: '<p class="stm-note">같은 기준의 후보 없음</p>';
|
||||
document
|
||||
.querySelectorAll(".jd-pick")
|
||||
.forEach((b) => (b.onclick = () => ($("jd-in").value = b.getAttribute("data-jd"))));
|
||||
};
|
||||
}
|
||||
|
||||
// ── 시간당 손료계수 수정 · 노무/장비 수량계산기(F6) ──
|
||||
function openXRate(ctx, api, code) {
|
||||
if (!need(ctx)) return;
|
||||
const c = code || selCode(ctx) || ctx.uiState.selectedIlwiCode || "";
|
||||
const by = Object.fromEntries((ctx.dbfData.costn_rows || []).map((r) => [UC.codeOf(r), r]));
|
||||
const rows = (ctx.dbfData.bdqty_rows || []).filter(
|
||||
(q) =>
|
||||
String(q.BDCODE || "").trim() === c &&
|
||||
by[String(q.CODE || "").trim()] &&
|
||||
String(by[String(q.CODE).trim()].UNIT || "").trim() === "천원",
|
||||
);
|
||||
const q0 = rows[0] ? num(rows[0].QTY1) : 0;
|
||||
const X = global.STMATE_XRATE;
|
||||
api.openModal(
|
||||
"시간당 손료계수 수정 [wM_Edit_XRate]",
|
||||
`<div class="stm-form">${rows.length ? `<p><strong>${esc(c)}</strong> ${esc((by[c] || {}).NAME)} — 기계가격 줄 ${esc(rows[0].CODE)} 수량 ${q0} (= 계 ${UA.lossSum(q0, 100)} × 10<sup>-7</sup> × 1,000)</p>` : `<p class="stm-warn">${esc(c || "(코드 없음)")} — 천원 단위 기계가격 구성 줄이 없음(중기 X 코드를 고르세요) · 아래 수량계산기만 씀</p>`}
|
||||
<div class="stm-row"><label>계수입력방법</label><select id="xr-way" class="win-select">${opt(
|
||||
[
|
||||
["0", "계 입력"],
|
||||
["1", "각계수 입력"],
|
||||
],
|
||||
"0",
|
||||
)}</select>
|
||||
<label>상각비 계수</label><input id="xr-a" type="number" class="win-input" style="width:80px;" /><label>정비비 계수</label><input id="xr-b" type="number" class="win-input" style="width:80px;" /><label>관리비 계수</label><input id="xr-c" type="number" class="win-input" style="width:80px;" /></div>
|
||||
<div class="stm-row"><label>계 (10<sup>-7</sup>)</label><input id="xr-sum" type="number" class="win-input" value="${UA.lossSum(q0, 100)}" style="width:100px;" /><label>손료 적용율</label><input id="xr-rate" type="number" class="win-input" value="100" style="width:70px;" />%<button class="win-btn primary" id="xr-ok" ${rows.length ? "" : "disabled"}>확 인(O)</button></div>
|
||||
<hr /><strong>수량계산기</strong>
|
||||
<div class="stm-row"><label>기준 시공량</label><input id="xq-q1" type="number" class="win-input" style="width:90px;" /><label>유형 / 조건</label><input id="xq-cond" class="win-input" value="1" style="width:110px;" /><label>적용율(%)</label><input id="xq-h" type="number" class="win-input" value="100" style="width:70px;" /></div>
|
||||
<div class="stm-row"><label>편성 수량</label><input id="xq-l" type="number" class="win-input" value="1" style="width:70px;" /><label>인력 / 장비</label><select id="xq-lab" class="win-select">${opt(
|
||||
[
|
||||
["1", "인력"],
|
||||
["0", "장비"],
|
||||
],
|
||||
"1",
|
||||
)}</select><label>소수 자리</label><input id="xq-p" type="number" class="win-input" value="3" style="width:50px;" /><label><input type="checkbox" id="xq-auto" /> 자동</label><button class="win-btn" id="xq-go">계산</button><span id="xq-out" class="stm-note"></span></div></div>`,
|
||||
);
|
||||
const sumNow = () =>
|
||||
$("xr-way").value === "1"
|
||||
? num(val("xr-a")) + num(val("xr-b")) + num(val("xr-c"))
|
||||
: num(val("xr-sum"));
|
||||
["xr-a", "xr-b", "xr-c"].forEach(
|
||||
(id) => ($(id).oninput = () => $("xr-way").value === "1" && ($("xr-sum").value = sumNow())),
|
||||
);
|
||||
$("xr-ok").onclick = () => {
|
||||
const q = UA.lossQty(sumNow(), num(val("xr-rate")));
|
||||
rows.forEach((r) => (r.QTY1 = String(q)));
|
||||
finish(ctx, api, `${c} 손료계수 계 ${sumNow()} × 적용율 ${val("xr-rate")}% → 수량 ${q}`);
|
||||
};
|
||||
$("xq-go").onclick = () => {
|
||||
if (!X) return ($("xq-out").textContent = "수량계산기(calc_xrate.js) 없음");
|
||||
const o = X.compute({
|
||||
qty1: val("xq-q1"),
|
||||
cond: val("xq-cond"),
|
||||
hrat: val("xq-h"),
|
||||
lqty: val("xq-l"),
|
||||
labor: val("xq-lab") === "1",
|
||||
point: val("xq-p"),
|
||||
auto: $("xq-auto").checked,
|
||||
});
|
||||
$("xq-out").textContent = o.ok
|
||||
? `일당 시공량 ${o.qday} · 적용 수량 ${o.cqty} (자리 ${o.point})`
|
||||
: o.error;
|
||||
};
|
||||
}
|
||||
|
||||
P.screens = P.screens || {};
|
||||
Object.assign(P.actions, {
|
||||
Act_1st_2nd: copySlot(1, 2),
|
||||
Act_2nd_1st: copySlot(2, 1),
|
||||
Act_UI_ChgAmt: (ctx, api) => openChgAmt(ctx, api),
|
||||
Act_UI_SetInit: (ctx, api) => openSetInit(ctx, api),
|
||||
Act_UI_XRate: (ctx, api) => openXRate(ctx, api),
|
||||
});
|
||||
P.menus.push(
|
||||
{ menu: "m1", act: "Act_UI_SetInit", label: "프로젝트 분류번호/시설코드/호환규정코드 초기화" },
|
||||
{ menu: "m4", act: "Act_UI_ChgAmt", label: "단가 일괄변경 · 물가변동" },
|
||||
{ menu: "m2", act: "Act_UI_XRate", label: "시간당 손료계수 수정 · 수량계산기 (F6)" },
|
||||
);
|
||||
// F6 — 대가표·단가목록표 화면에서 손료계수 창
|
||||
document.addEventListener("keydown", (e) => {
|
||||
const A = global.STMATE_APP;
|
||||
if (e.key !== "F6" || !A || !/^(wM_Edit_iLWi|wM_JabPum)$/.test(A.ctx.uiState.activeScreen))
|
||||
return;
|
||||
e.preventDefault();
|
||||
openXRate(A.ctx, A);
|
||||
});
|
||||
global.STMATE_UI_DAN2 = { openChgAmt, openHal, openSetInit, openJD, openXRate, syncState };
|
||||
})(window);
|
||||
@@ -0,0 +1,394 @@
|
||||
/**
|
||||
* STMATE w 8.0 — 다른 프로젝트 자료 창 끼워 넣기 (scr_ui_merge.js)
|
||||
* 프로젝트 DATA검색(wSrch_PL) · 설계예산시스템 코드병합(wM_Stmate_Merge_Code) · 데이타병합(uM_Merge) · 잡비 금액 불러오기(wM_GetAMT)
|
||||
* 근거: DFM 원문 — wSrch_PL(wcBx_Type 4택 · 검색결과 내 재검색 · 열: 프로젝트명·코드번호·명칭·규격·단위·분류번호·시설코드·계·노·재·경·
|
||||
* 호환규정코드·적용기준일 · 프로젝트 찾아가기) · wM_Stmate_Merge_Code(rg_Set_Sys 4택 · 코드전송시 전송여부 묻기)
|
||||
* · uM_Merge(병합기준선택 · 단가목록표 · 병합코드 확인/검토 · 최종확인 신규코드번호부여 · 내역서(Ctrl+N))
|
||||
* · wM_GetAMT(공종 추려보기 6택 · 필터 · 복수선택 합산 · 금액전송)
|
||||
* 계산 = ui_merge.js · 검색 = /api/ui/search(api_ui_code.py) · 복사원 자료 = /api/load
|
||||
*/
|
||||
(function (global) {
|
||||
"use strict";
|
||||
const P = (global.STMATE_PLUGINS = global.STMATE_PLUGINS || {
|
||||
screens: {},
|
||||
actions: {},
|
||||
menus: [],
|
||||
});
|
||||
const UM = global.STMATE_UI_MERGE;
|
||||
const UC = global.STMATE_UI_CODE;
|
||||
const B = global.STMATE_UI_BOQ;
|
||||
const esc = (s) =>
|
||||
String(s == null ? "" : s).replace(
|
||||
/[&<>"]/g,
|
||||
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c],
|
||||
);
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const num = (v) => Number(String(v == null ? "" : v).replace(/,/g, "")) || 0;
|
||||
const env = (ctx) => ({
|
||||
d: ctx.dbfData,
|
||||
state: ctx.state,
|
||||
memo: ctx.uiState.editedSmtMap || (ctx.uiState.editedSmtMap = {}),
|
||||
});
|
||||
const others = (ctx) =>
|
||||
(ctx.sourcesData.projects || []).filter((p) => p.path !== ctx.currentSourcePath);
|
||||
const prjOpts = (ctx, cur) =>
|
||||
others(ctx)
|
||||
.map(
|
||||
(p) =>
|
||||
`<option value="${esc(p.path)}" ${p.path === cur ? "selected" : ""}>${esc(p.name)}</option>`,
|
||||
)
|
||||
.join("");
|
||||
const SRC = {}; // 복사원 자료 — 경로별 한 번
|
||||
async function srcData(api, path) {
|
||||
if (!SRC[path]) SRC[path] = await api.postJson("/api/load", { source_path: path });
|
||||
return SRC[path];
|
||||
}
|
||||
|
||||
// ── 프로젝트 DATA검색 ──
|
||||
const TYPES = [
|
||||
["LMS", "노무비, 재료비, 경비"],
|
||||
["XBD", "중기비, 일위대가, 단가산출"],
|
||||
["W", "일식/표준시장"],
|
||||
["BOQ", "내역서"],
|
||||
];
|
||||
function srchHtml(ctx, api) {
|
||||
const s = ctx.uiState.srch;
|
||||
const rows = s.rows || [];
|
||||
const isBoq = s.kind === "BOQ";
|
||||
const head = isBoq
|
||||
? "<th>No</th><th>프로젝트명</th><th>공종</th><th>명칭</th><th>규격</th><th>수량</th><th>단위</th><th>계금액</th><th>코드</th><th></th>"
|
||||
: "<th>No</th><th>프로젝트명</th><th>코드번호</th><th>명칭</th><th>규격</th><th>단위</th><th>분류번호</th><th>시설코드</th><th>계</th><th>노무비</th><th>재료비</th><th>경비</th><th>호환규정코드</th><th>적용기준일</th><th></th>";
|
||||
const go = (i) =>
|
||||
`<button class="win-btn sp-go" data-i="${i}">찾아가기</button>${isBoq ? "" : `<button class="win-btn sp-get" data-i="${i}">가져오기</button>`}`;
|
||||
const body = rows
|
||||
.map((r, i) =>
|
||||
isBoq
|
||||
? `<tr><td class="center">${i + 1}</td><td>${esc(r.project)}</td><td>${esc(r.ITNUM)}</td><td>${esc(r.NAME1)}</td><td>${esc(r.SIZE1)}</td><td class="num">${esc(r.QTY1)}</td><td class="center">${esc(r.UNIT1)}</td><td class="num">${api.fmt(num(r.T_AMT1))}</td><td class="center">${esc(r.BDCODE1)}</td><td>${go(i)}</td></tr>`
|
||||
: `<tr><td class="center">${i + 1}</td><td>${esc(r.project)}</td><td class="center">${esc(r.CODE)}</td><td>${esc(r.NAME)}</td><td>${esc(r.SIZE)}</td><td class="center">${esc(r.UNIT)}</td><td>${esc(r.BQCODE)}</td><td>${esc(r.EQCODE)}</td><td class="num">${api.fmt(num(r.TAMT1))}</td><td class="num">${api.fmt(num(r.LAMT1))}</td><td class="num">${api.fmt(num(r.MAMT1))}</td><td class="num">${api.fmt(num(r.SAMT1))}</td><td>${esc(r.JDCODE)}</td><td>${esc(r.EDT_DATE)}</td><td>${go(i)}</td></tr>`,
|
||||
)
|
||||
.join("");
|
||||
return `<div class="stm-form"><div class="stm-row"><select id="sp-kind" class="win-select">${TYPES.map(([k, t]) => `<option value="${k}" ${s.kind === k ? "selected" : ""}>${t}</option>`).join("")}</select>
|
||||
<input id="sp-kw" class="win-input" value="${esc(s.kw)}" placeholder="명칭, 규격 (띄어 쓰면 모두 든 것)" style="width:240px;" />
|
||||
<label><input type="checkbox" id="sp-re" ${s.re ? "checked" : ""} /> 검색결과 내 재검색</label><button class="win-btn primary" id="sp-go">검 색</button>
|
||||
<span class="stm-note">${rows.length}건${s.truncated ? " (앞 500건)" : ""}${s.err ? ` · 못 연 프로젝트 ${s.err}` : ""}</span></div>
|
||||
<div class="grid-scroll" style="max-height:60vh;"><table class="dbgrid"><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table></div></div>`;
|
||||
}
|
||||
function openSrch(ctx, api) {
|
||||
const s = (ctx.uiState.srch = ctx.uiState.srch || { kind: "LMS", kw: "", rows: [], re: false });
|
||||
api.openModal("프로젝트 DATA검색(명칭, 규격) [wSrch_PL]", srchHtml(ctx, api));
|
||||
$("sp-kind").onchange = (e) => (s.kind = e.target.value);
|
||||
$("sp-kw").onchange = (e) => (s.kw = e.target.value);
|
||||
$("sp-re").onchange = (e) => (s.re = e.target.checked);
|
||||
$("sp-go").onclick = async () => {
|
||||
s.kw = $("sp-kw").value;
|
||||
if (s.re && s.rows.length) {
|
||||
const w = s.kw.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
const hay = (r) => `${r.NAME || r.NAME1 || ""} ${r.SIZE || r.SIZE1 || ""}`.toLowerCase();
|
||||
s.rows = s.rows.filter((r) => w.every((x) => hay(r).includes(x)));
|
||||
} else {
|
||||
const res = await api.postJson("/api/ui/search", { kind: s.kind, kw: s.kw });
|
||||
Object.assign(s, {
|
||||
rows: res.rows,
|
||||
truncated: res.truncated,
|
||||
err: (res.errors || []).length,
|
||||
});
|
||||
}
|
||||
openSrch(ctx, api);
|
||||
};
|
||||
document.querySelectorAll(".sp-go").forEach(
|
||||
(b) =>
|
||||
(b.onclick = () => {
|
||||
const r = s.rows[Number(b.getAttribute("data-i"))];
|
||||
api.closeModal();
|
||||
api.loadProjectFromServer(r.path, s.kind === "BOQ" ? "wBoq" : "wM_JabPum").then(() => {
|
||||
if (s.kind !== "BOQ")
|
||||
ctx.uiState.dan = Object.assign(ctx.uiState.dan || {}, {
|
||||
kind: "ALL",
|
||||
kw: r.CODE,
|
||||
code: r.CODE,
|
||||
});
|
||||
api.renderWorkspace();
|
||||
});
|
||||
}),
|
||||
);
|
||||
document.querySelectorAll(".sp-get").forEach(
|
||||
(b) =>
|
||||
(b.onclick = () => {
|
||||
const r = s.rows[Number(b.getAttribute("data-i"))];
|
||||
openMergeCode(ctx, api, { path: r.path, codes: [r.CODE] });
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ── 코드병합 · 데이타병합 ──
|
||||
const KINDS = ["L", "M", "S", "B", "X", "D", "W"];
|
||||
const POL_KINDS = ["L", "M", "S", "B", "D", "W"];
|
||||
function mgHtml(ctx, api, m) {
|
||||
const src = m.src;
|
||||
const list = src
|
||||
? src.costn_rows.filter(
|
||||
(r) =>
|
||||
UC.codeOf(r)[0] === m.kind &&
|
||||
(!m.kw || `${r.CODE} ${r.NAME} ${r.SIZE}`.toLowerCase().includes(m.kw.toLowerCase())),
|
||||
)
|
||||
: [];
|
||||
const tabs = KINDS.map(
|
||||
(k) =>
|
||||
`<button class="win-btn mg-kind${m.kind === k ? " primary" : ""}" data-k="${k}">${UC.KIND_NAME[k]}</button>`,
|
||||
).join("");
|
||||
const pol = m.data
|
||||
? `<div class="stm-row">${POL_KINDS.map(
|
||||
(k) =>
|
||||
`<label>${UC.KIND_NAME[k]} <select class="win-select mg-pol" data-k="${k}">${UM.POLICY.map((t, i) => `<option value="${i}" ${Number(m.policy[k] || 0) === i ? "selected" : ""}>${t}</option>`).join("")}</select></label>`,
|
||||
).join(" ")}</div>
|
||||
<div class="stm-row"><label>동일항목 연결지정 <select id="mg-conn" class="win-select">${["1차:호환,2차:시설,3차:명칭..단위", "1차:호환,2차:명칭+규격+단위", "명칭+규격+단위로 동일항목 검색"].map((t, i) => `<option value="${i}" ${m.conn === i ? "selected" : ""}>${t}</option>`).join("")}${m.byCode ? '<option value="code" selected>코드번호로 인식</option>' : ""}</select></label>
|
||||
<label><input type="checkbox" id="mg-bdw" ${m.bdW ? "checked" : ""} /> 대가,산근 → 일식단가(W코드)로 생성</label><label><input type="checkbox" id="mg-xw" ${m.xW ? "checked" : ""} /> 중기비 → 일식단가(W코드)로 생성</label></div>`
|
||||
: "";
|
||||
const plan = (m.plan || [])
|
||||
.map(
|
||||
(p) =>
|
||||
`<tr><td class="center">${p.src}</td><td class="center">${{ map: "복사처적용", copy: "복사원적용", new: "신규등록", w: "일식단가(W)" }[p.act]}</td><td class="center"><strong>${p.dest}</strong></td><td>${esc((src.costn_rows.find((r) => UC.codeOf(r) === p.src) || {}).NAME)}</td></tr>`,
|
||||
)
|
||||
.join("");
|
||||
return `<div class="stm-form"><div class="stm-row"><label>복사원</label><select id="mg-src" class="win-select" style="max-width:420px;">${prjOpts(ctx, m.path)}</select><button class="win-btn" id="mg-open">불러오기</button><span class="stm-note">복사처 = 지금 프로젝트</span></div>
|
||||
${
|
||||
src
|
||||
? `<div class="stm-row">${tabs}<input id="mg-kw" class="win-input" value="${esc(m.kw || "")}" placeholder="코드·명칭" style="width:140px;" /></div>
|
||||
<div class="grid-scroll" style="max-height:30vh;"><table class="dbgrid"><thead><tr><th>선택</th><th>코드</th><th>명칭</th><th>규격</th><th>단위</th><th>계</th><th>호환규정코드</th></tr></thead><tbody>${list
|
||||
.map(
|
||||
(r) =>
|
||||
`<tr><td class="center"><input type="checkbox" class="mg-sel" data-c="${UC.codeOf(r)}" ${m.sel[UC.codeOf(r)] ? "checked" : ""} /></td><td class="center">${UC.codeOf(r)}</td><td>${esc(r.NAME)}</td><td>${esc(r.SIZE)}</td><td class="center">${esc(r.UNIT)}</td><td class="num">${api.fmt(num(r.TAMT1))}</td><td>${esc(r.JDCODE || "")}</td></tr>`,
|
||||
)
|
||||
.join("")}</tbody></table></div>
|
||||
<div class="stm-row"><label>코드병합 내용 선택 <select id="mg-mode" class="win-select">${["선택코드만 병합", "선택코드+하위기초단가코드 병합", "모든코드(하위 산근,대가포함) 병합", "코드병합 취소"].map((t, i) => `<option value="${i}" ${m.mode === i ? "selected" : ""}>${t}</option>`).join("")}</select></label></div>${pol}
|
||||
<div class="stm-row"><button class="win-btn" id="mg-check">병합코드 확인/검토</button><button class="win-btn primary" id="mg-ok" ${m.plan ? "" : "disabled"}>확 인(병합)</button><span class="stm-note">※ 단 복사처에 등록되지 않은코드 : 코드번호 생성(병합)</span></div>
|
||||
${plan ? `<div class="grid-scroll" style="max-height:24vh;"><table class="dbgrid"><thead><tr><th>복사원코드</th><th>적용방법</th><th>복사처코드</th><th>명칭</th></tr></thead><tbody>${plan}</tbody></table></div>` : ""}`
|
||||
: ""
|
||||
}</div>`;
|
||||
}
|
||||
function openMergeCode(ctx, api, o) {
|
||||
if (!ctx.dbfData) return alert("열린 프로젝트가 없음");
|
||||
const prev = ctx.uiState.mg;
|
||||
const m = (ctx.uiState.mg = Object.assign(
|
||||
{ kind: "B", sel: {}, mode: 2, conn: 2, policy: {}, data: false, byCode: false },
|
||||
prev && !(o && o.reset) ? prev : {},
|
||||
o || {},
|
||||
));
|
||||
if (o && o.codes) o.codes.forEach((c) => ((m.sel[c] = true), (m.kind = c[0])));
|
||||
if (o && o.path && (!m.src || m.src.source_path !== o.path))
|
||||
return srcData(api, o.path).then(
|
||||
(d) => ((m.src = d), (m.path = o.path), openMergeCode(ctx, api)),
|
||||
);
|
||||
const title = m.data
|
||||
? `데이타 병합 【 복사처 <= 복사원 】 [uM_Merge${m.byCode ? " · 코드번호로 인식" : ""}]`
|
||||
: "설계예산시스템 코드병합 [wM_Stmate_Merge_Code]";
|
||||
api.openModal(title, mgHtml(ctx, api, m));
|
||||
const re = () => openMergeCode(ctx, api);
|
||||
$("mg-open").onclick = () =>
|
||||
openMergeCode(ctx, api, { path: $("mg-src").value, sel: {}, plan: null });
|
||||
if (!m.src) return;
|
||||
document
|
||||
.querySelectorAll(".mg-kind")
|
||||
.forEach((b) => (b.onclick = () => ((m.kind = b.getAttribute("data-k")), re())));
|
||||
document
|
||||
.querySelectorAll(".mg-sel")
|
||||
.forEach(
|
||||
(i) =>
|
||||
(i.onchange = () => ((m.sel[i.getAttribute("data-c")] = i.checked), (m.plan = null))),
|
||||
);
|
||||
document
|
||||
.querySelectorAll(".mg-pol")
|
||||
.forEach(
|
||||
(s) =>
|
||||
(s.onchange = () => (
|
||||
(m.policy[s.getAttribute("data-k")] = Number(s.value)),
|
||||
(m.plan = null)
|
||||
)),
|
||||
);
|
||||
$("mg-kw").onchange = (e) => ((m.kw = e.target.value), re());
|
||||
$("mg-mode").onchange = (e) => ((m.mode = Number(e.target.value)), (m.plan = null));
|
||||
if ($("mg-conn"))
|
||||
$("mg-conn").onchange = (e) => (
|
||||
(m.conn = e.target.value === "code" ? "code" : Number(e.target.value)),
|
||||
(m.plan = null)
|
||||
);
|
||||
if ($("mg-bdw")) $("mg-bdw").onchange = (e) => ((m.bdW = e.target.checked), (m.plan = null));
|
||||
if ($("mg-xw")) $("mg-xw").onchange = (e) => ((m.xW = e.target.checked), (m.plan = null));
|
||||
$("mg-check").onclick = () => {
|
||||
if (m.mode === 3) return ((m.sel = {}), (m.plan = null), re()); // 코드병합 취소
|
||||
const codes = Object.keys(m.sel).filter((c) => m.sel[c]);
|
||||
if (!codes.length) return alert("병합할 코드를 고르세요");
|
||||
m.plan = UM.plan(env(ctx), m.src, {
|
||||
codes,
|
||||
mode: m.mode,
|
||||
conn: m.byCode ? "code" : m.conn,
|
||||
policy: m.data ? m.policy : {},
|
||||
bdW: m.data && m.bdW,
|
||||
xW: m.data && m.xW,
|
||||
});
|
||||
re();
|
||||
};
|
||||
$("mg-ok").onclick = () => {
|
||||
if (!m.plan) return;
|
||||
const res = UM.apply(env(ctx), m.src, m.plan);
|
||||
const c = res.count;
|
||||
ctx.uiState.danMsg = `병합 — 신규 ${c.new} · 복사원적용 ${c.copy} · 복사처적용 ${c.map} · W ${c.w}${res.unresolved.length ? ` · 못 이은 하위코드 ${res.unresolved.join(",")}` : ""}`;
|
||||
Object.assign(m, { plan: null, sel: {} });
|
||||
api.closeModal();
|
||||
api.cascadeAndRender();
|
||||
};
|
||||
}
|
||||
|
||||
// ── 잡비 금액 불러오기 ──
|
||||
const LV = [
|
||||
" 모두보기",
|
||||
" 소 계(+=)",
|
||||
" 계(+==)",
|
||||
" 합 계(+===)",
|
||||
" 총 계(+====)",
|
||||
" 총합계(+=====)",
|
||||
];
|
||||
async function openGetAmt(ctx, api) {
|
||||
if (!ctx.dbfData) return alert("열린 프로젝트가 없음");
|
||||
const g = (ctx.uiState.getAmt = ctx.uiState.getAmt || { path: "", lv: 0, flt: "", sel: {} });
|
||||
const rows = g.path
|
||||
? (await srcData(api, g.path)).boq_rows
|
||||
: global.STMATE_VIEW_PRJ_BOQ.evaluateFullBoqRows(ctx);
|
||||
const words = g.flt.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
const list = g.lv
|
||||
? B.groupTotals(rows, g.lv).map((x) => ({
|
||||
key: `g${x.recno}`,
|
||||
it: x.itnum,
|
||||
name: x.name,
|
||||
size: "",
|
||||
qty: "",
|
||||
unit: "",
|
||||
amt: x.sum.T,
|
||||
}))
|
||||
: rows
|
||||
.filter(B.isLeaf)
|
||||
.map((r) => ({
|
||||
key: `r${r._recno}`,
|
||||
it: r.ITNUM || "",
|
||||
name: r.NAME1 || "",
|
||||
size: r.SIZE1 || "",
|
||||
qty: r.QTY1,
|
||||
unit: r.UNIT1 || "",
|
||||
amt: num(r.T_AMT1),
|
||||
}));
|
||||
const shown = list.filter((x) =>
|
||||
words.every((w) => `${x.name} ${x.size}`.toLowerCase().includes(w)),
|
||||
);
|
||||
const sum = list.filter((x) => g.sel[x.key]).reduce((a, x) => a + x.amt, 0);
|
||||
const keys = Object.keys((ctx.state.kanjubConfig && ctx.state.kanjubConfig.keys) || {}).filter(
|
||||
(k) => /^C_/.test(k),
|
||||
);
|
||||
api.openModal(
|
||||
"잡비 금액 불러오기 [wM_GetAMT]",
|
||||
`<div class="stm-form"><div class="stm-row"><select id="ga-prj" class="win-select" style="max-width:380px;"><option value="">(지금 프로젝트)</option>${prjOpts(ctx, g.path)}</select>
|
||||
<select id="ga-lv" class="win-select">${LV.map((t, i) => `<option value="${i}" ${g.lv === i ? "selected" : ""}>${t}${i ? " 이상" : ""}</option>`).join("")}</select>
|
||||
<input id="ga-flt" class="win-input" value="${esc(g.flt)}" placeholder="필터(명칭, 규격)" style="width:150px;" /></div>
|
||||
<div class="grid-scroll" style="max-height:48vh;"><table class="dbgrid"><thead><tr><th></th><th>공종</th><th>명칭</th><th>규격</th><th>수량</th><th>단위</th><th>계금액</th></tr></thead><tbody>${shown
|
||||
.map(
|
||||
(x) =>
|
||||
`<tr><td class="center"><input type="checkbox" class="ga-sel" data-k="${x.key}" ${g.sel[x.key] ? "checked" : ""} /></td><td>${esc(x.it)}</td><td>${esc(x.name)}</td><td>${esc(x.size)}</td><td class="num">${esc(x.qty)}</td><td class="center">${esc(x.unit)}</td><td class="num">${api.fmt(x.amt)}</td></tr>`,
|
||||
)
|
||||
.join("")}</tbody></table></div>
|
||||
<div class="stm-row"><strong>선택 합산 ${api.fmt(sum)}원</strong><label>보낼 잡비 항목(직접입력 금액)</label><select id="ga-key" class="win-select">${keys.map((k) => `<option value="${k}">${k.slice(2)}</option>`).join("")}</select><button class="win-btn primary" id="ga-ok" ${keys.length ? "" : "disabled"}>금액전송</button></div></div>`,
|
||||
);
|
||||
const re = () => openGetAmt(ctx, api);
|
||||
$("ga-prj").onchange = (e) => ((g.path = e.target.value), (g.sel = {}), re());
|
||||
$("ga-lv").onchange = (e) => ((g.lv = Number(e.target.value)), (g.sel = {}), re());
|
||||
$("ga-flt").onchange = (e) => ((g.flt = e.target.value), re());
|
||||
document
|
||||
.querySelectorAll(".ga-sel")
|
||||
.forEach((i) => (i.onchange = () => ((g.sel[i.getAttribute("data-k")] = i.checked), re())));
|
||||
$("ga-ok").onclick = () => {
|
||||
const k = $("ga-key").value.slice(2);
|
||||
const cfg = ctx.state.kanjubConfig;
|
||||
cfg.keys["C_" + k] = Math.trunc(sum);
|
||||
cfg.keys["D_" + k] = 1; // 고정(직접입력 금액 사용)
|
||||
ctx.uiState.danMsg = `잡비 ${k} ← ${sum}원(금액전송)`;
|
||||
api.closeModal();
|
||||
api.renderWorkspace();
|
||||
};
|
||||
}
|
||||
|
||||
// ── 자료파일 업데이트(wM_Mrg_BSC) — 복사원 = 다른 프로젝트 단가목록(원본 기준자료 BSC 는 암호 자료) ──
|
||||
const MKEY = [
|
||||
["JD", "호환규정코드"],
|
||||
["EQ", "시설코드"],
|
||||
["NSU", "명칭/규격/단위"],
|
||||
["JNSU", "호환+명+규+단"],
|
||||
["NU", "명칭/단위"],
|
||||
["", "설정안함"],
|
||||
];
|
||||
async function openMrgBsc(ctx, api) {
|
||||
if (!ctx.dbfData) return alert("열린 프로젝트가 없음");
|
||||
const b = (ctx.uiState.mrgBsc = ctx.uiState.mrgBsc || {
|
||||
path: "",
|
||||
keys: ["JD", "NSU", "NU", "", ""],
|
||||
name: 0,
|
||||
amt: 1,
|
||||
jd: 0,
|
||||
});
|
||||
const sel = (id, list, cur) =>
|
||||
`<select id="${id}" class="win-select">${list.map(([v, t]) => `<option value="${v}" ${String(v) === String(cur) ? "selected" : ""}>${t}</option>`).join("")}</select>`;
|
||||
const UPD = [
|
||||
[0, "업데이트 안함(복사처적용)"],
|
||||
[1, "업데이트 (복사원적용)"],
|
||||
];
|
||||
api.openModal(
|
||||
"자료파일 업데이트 [wM_Mrg_BSC]",
|
||||
`<div class="stm-form"><div class="stm-row"><label>복사원</label><select id="mb-src" class="win-select" style="max-width:420px;">${prjOpts(ctx, b.path)}</select></div>
|
||||
<div class="stm-row">${[0, 1, 2, 3, 4].map((i) => `<label>${i + 1}차</label>${sel(`mb-k${i}`, MKEY, b.keys[i])}`).join("")}</div>
|
||||
<div class="stm-row"><label>명칭</label>${sel("mb-name", UPD, b.name)}<label>단가</label>${sel("mb-amt", UPD, b.amt)}<label>조달코드</label>${sel(
|
||||
"mb-jd",
|
||||
UPD.concat([
|
||||
[2, "복사처 우선적용 업데이트"],
|
||||
[3, "복사원 우선적용 업데이트"],
|
||||
]),
|
||||
b.jd,
|
||||
)}</div>
|
||||
<p class="stm-note">대상 = 노무·재료·경비·견적(W) · 원본 기준자료(ST_BSC)는 암호 자료라 다른 프로젝트 단가목록을 복사원으로 씀</p>
|
||||
<div class="stm-row"><button class="win-btn primary" id="mb-ok">업데이트</button></div></div>`,
|
||||
);
|
||||
$("mb-ok").onclick = async () => {
|
||||
Object.assign(b, {
|
||||
path: $("mb-src").value,
|
||||
keys: [0, 1, 2, 3, 4].map((i) => $(`mb-k${i}`).value),
|
||||
name: Number($("mb-name").value),
|
||||
amt: Number($("mb-amt").value),
|
||||
jd: Number($("mb-jd").value),
|
||||
});
|
||||
const src = await srcData(api, b.path);
|
||||
const ch = UM.mrgBsc(ctx.dbfData.costn_rows, src.costn_rows, b);
|
||||
if (global.STMATE_UI_DAN2)
|
||||
global.STMATE_UI_DAN2.syncState(
|
||||
ctx,
|
||||
ch.filter((x) => x.fields.some((f) => /AMT1$/.test(f))).map((x) => x.code),
|
||||
);
|
||||
if (ch.some((x) => x.fields.includes("JDCODE"))) ctx.dbfData.ui_dirty = true;
|
||||
ctx.uiState.danMsg = `자료파일 업데이트 ${ch.length}건`;
|
||||
api.closeModal();
|
||||
api.cascadeAndRender();
|
||||
};
|
||||
}
|
||||
|
||||
Object.assign(P.actions, {
|
||||
Act_UI_MrgBsc: (ctx, api) => openMrgBsc(ctx, api),
|
||||
Act_Merge: (ctx, api) => openMergeCode(ctx, api, { data: true, byCode: false, reset: true }),
|
||||
Act_Merge_C: (ctx, api) => openMergeCode(ctx, api, { data: true, byCode: true, reset: true }),
|
||||
Act_UI_MergeCode: (ctx, api) =>
|
||||
openMergeCode(ctx, api, { data: false, byCode: false, reset: true }),
|
||||
Act_UI_SrchPL: (ctx, api) => openSrch(ctx, api),
|
||||
Act_UI_GetAmt: (ctx, api) => openGetAmt(ctx, api),
|
||||
});
|
||||
P.menus.push(
|
||||
{ menu: "m4", act: "Act_UI_MergeCode", label: "설계예산시스템 코드병합" },
|
||||
{ menu: "m4", act: "Act_UI_SrchPL", label: "프로젝트 DATA검색(명칭, 규격)" },
|
||||
{ menu: "m3", act: "Act_UI_GetAmt", label: "잡비 금액 불러오기" },
|
||||
{ menu: "m2", act: "Act_UI_MrgBsc", label: "단가(명칭,규격, 호환규정코드..) 업데이트" },
|
||||
);
|
||||
global.STMATE_UI_MERGE_SCR = { openSrch, openMergeCode, openGetAmt, openMrgBsc };
|
||||
})(window);
|
||||
@@ -163,16 +163,34 @@
|
||||
api.renderWorkspace();
|
||||
};
|
||||
|
||||
function needActive(ctx) {
|
||||
const r = selRow(ctx);
|
||||
if (r && String(r.PCODE).trim() !== activeCode(ctx)) {
|
||||
alert(
|
||||
`BOQ${String(r.PCODE).trim()}.DBF 내역서 열기는 서버 불러오기 대기 — 지금 열린 내역서는 BOQ${activeCode(ctx)}`,
|
||||
);
|
||||
// 다른 내역서 열기 — 고른 공사 행의 BOQ{PCODE}.DBF 로 바꿔 불러옴(/api/ui/load-boq · api_ui_code.py)
|
||||
async function openBoq(ctx, api, pcode) {
|
||||
const d = ctx.dbfData;
|
||||
const dirty =
|
||||
d && (d.ui_dirty || (d.boq_deleted || []).length || (d.boq_rows || []).some((r) => r._new));
|
||||
if (dirty && !confirm("지금 내역서에 저장 안 한 손질이 있음 — 버리고 다른 내역서를 열까요?"))
|
||||
return false;
|
||||
try {
|
||||
const data = await api.postJson("/api/ui/load-boq", {
|
||||
source_path: ctx.currentSourcePath,
|
||||
pcode,
|
||||
});
|
||||
ctx.dbfData = data;
|
||||
ctx.state = data.sim_state;
|
||||
Object.assign(ctx.uiState, { editedSmtMap: {}, boqSel: [], sysDraft: null, prjSel: pcode });
|
||||
return true;
|
||||
} catch (err) {
|
||||
alert("내역서 열기 실패: " + err.message);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// 고른 공사가 열린 내역서가 아니면 먼저 연 뒤 이어서
|
||||
const withActive = (fn) => (ctx, api) => {
|
||||
const r = selRow(ctx);
|
||||
const pc = r ? String(r.PCODE).trim() : "";
|
||||
if (!r || pc === activeCode(ctx)) return fn(ctx, api);
|
||||
openBoq(ctx, api, pc).then((ok) => ok && fn(ctx, api));
|
||||
};
|
||||
|
||||
function editMeta(ctx, api) {
|
||||
const r = selRow(ctx);
|
||||
@@ -205,11 +223,11 @@
|
||||
}
|
||||
|
||||
const H = {
|
||||
ActEditBoq: (ctx, api) => needActive(ctx) && go(ctx, api, "wBoq"),
|
||||
ActEditBoq: withActive((ctx, api) => go(ctx, api, "wBoq")),
|
||||
ActHaeCost: (ctx, api) => go(ctx, api, "wM_JabPum"),
|
||||
Act_ED_PNUM: editMeta,
|
||||
Act_Ed_Jab: (ctx, api) => needActive(ctx) && go(ctx, api, "wM_KanJub"),
|
||||
Act_CalBoq: (ctx, api) => needActive(ctx) && api.cascadeAndRender(),
|
||||
Act_Ed_Jab: withActive((ctx, api) => go(ctx, api, "wM_KanJub")),
|
||||
Act_CalBoq: withActive((ctx, api) => api.cascadeAndRender()),
|
||||
Act_Calc_Hae: (ctx, api) => api.cascadeAndRender(),
|
||||
Act_Prn: (ctx, api) => go(ctx, api, "PM_Prt"),
|
||||
ActMkCont00: stage("Act_Stg_Contract"),
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* STMATE w 8.0 — 글자 입력 도우미·내역 보조 창 끼워 넣기 (scr_ui_text.js)
|
||||
* 특수문자(mMinMsg_ · 14종 · 문자 입력후 종료 · 다중문자 동시입력) · 사용자문자 편집(wM_Edit_USER · 검색명칭/입력내용/적용)
|
||||
* · 메모 입력(wInDlgMemo · Ok/Cancel/Load/Clear — 내역 행 DES1·DES2) · 동일코드 추려보기(wSameCode «추려보기» · N번 사용중 · 더블클릭 → 내역 위치)
|
||||
* · 실적단가 → 일식단가 변환등록(wM_Conv_A2W · 항목선택 → 비목율 → 변환코드 → 변환/생성)
|
||||
* 글자 넣는 자리 = 마지막으로 손댄 입력칸·글상자(커서 자리) · 사용자문자 저장 = 브라우저(localStorage, 원본 USR_SYNC.DBF 자리)
|
||||
*/
|
||||
(function (global) {
|
||||
"use strict";
|
||||
const P = (global.STMATE_PLUGINS = global.STMATE_PLUGINS || {
|
||||
screens: {},
|
||||
actions: {},
|
||||
menus: [],
|
||||
});
|
||||
const UA = global.STMATE_UI_CODE_AMT;
|
||||
const UC = global.STMATE_UI_CODE;
|
||||
const esc = (s) =>
|
||||
String(s == null ? "" : s).replace(
|
||||
/[&<>"]/g,
|
||||
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c],
|
||||
);
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const num = (v) => Number(String(v == null ? "" : v).replace(/,/g, "")) || 0;
|
||||
|
||||
// 마지막 입력 자리 — 창(모달) 안 입력칸은 빼고 작업 화면 칸만 기억
|
||||
let target = null;
|
||||
document.addEventListener("focusin", (e) => {
|
||||
const el = e.target;
|
||||
if (
|
||||
/^(INPUT|TEXTAREA)$/.test(el.tagName) &&
|
||||
!el.closest("#stmate-modal-window") &&
|
||||
el.type !== "checkbox"
|
||||
)
|
||||
target = el;
|
||||
});
|
||||
function insertText(s) {
|
||||
const el = target;
|
||||
if (!el || !document.body.contains(el))
|
||||
return (navigator.clipboard && navigator.clipboard.writeText(s), false);
|
||||
const a = el.selectionStart ?? el.value.length,
|
||||
b = el.selectionEnd ?? el.value.length;
|
||||
el.value = el.value.slice(0, a) + s + el.value.slice(b);
|
||||
el.selectionStart = el.selectionEnd = a + s.length;
|
||||
el.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
el.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
return true;
|
||||
}
|
||||
const where = () =>
|
||||
target && document.body.contains(target)
|
||||
? `넣을 자리: ${target.id || target.className || target.tagName}`
|
||||
: "넣을 자리 없음 — 클립보드로 복사";
|
||||
|
||||
// ── 특수문자 ──
|
||||
const dec = typeof TextDecoder !== "undefined" ? new TextDecoder("euc-kr") : null;
|
||||
function openSpChar(ctx, api) {
|
||||
const s = (ctx.uiState.spc = ctx.uiState.spc || { i: 0, close: true, multi: false, buf: "" });
|
||||
const chars = dec ? UA.specialChars(s.i, (b) => dec.decode(b)) : [];
|
||||
api.openModal(
|
||||
"특수문자 [mMinMsg_]",
|
||||
`<div class="stm-form" style="display:flex;gap:10px;"><select id="sc-kind" size="14" class="win-select">${UA.SPECIAL.map(([t], i) => `<option value="${i}" ${s.i === i ? "selected" : ""}>${t}</option>`).join("")}</select>
|
||||
<div><div style="display:grid;grid-template-columns:repeat(16,26px);gap:2px;max-height:50vh;overflow:auto;">${chars.map((c) => `<button class="win-btn sc-ch" style="padding:2px;">${esc(c)}</button>`).join("")}</div>
|
||||
<div class="stm-row"><label>입력될문자</label><input id="sc-buf" class="win-input" value="${esc(s.buf)}" style="width:160px;" /><label><input type="checkbox" id="sc-close" ${s.close ? "checked" : ""} /> 문자 입력후 종료</label><label><input type="checkbox" id="sc-multi" ${s.multi ? "checked" : ""} /> 다중문자 동시입력</label><button class="win-btn primary" id="sc-put">입력</button></div>
|
||||
<p class="stm-note">${esc(where())}</p></div></div>`,
|
||||
);
|
||||
$("sc-kind").onchange = (e) => ((s.i = Number(e.target.value)), openSpChar(ctx, api));
|
||||
$("sc-close").onchange = (e) => (s.close = e.target.checked);
|
||||
$("sc-multi").onchange = (e) => (s.multi = e.target.checked);
|
||||
const put = (t) => {
|
||||
insertText(t);
|
||||
s.buf = "";
|
||||
if (s.close) api.closeModal();
|
||||
};
|
||||
document
|
||||
.querySelectorAll(".sc-ch")
|
||||
.forEach(
|
||||
(b) =>
|
||||
(b.onclick = () =>
|
||||
s.multi
|
||||
? (($("sc-buf").value += b.textContent), (s.buf = $("sc-buf").value))
|
||||
: put(b.textContent)),
|
||||
);
|
||||
$("sc-put").onclick = () => put($("sc-buf").value);
|
||||
}
|
||||
|
||||
// ── 사용자문자 편집 ──
|
||||
const UKEY = "stmate_user_text";
|
||||
const loadU = () => {
|
||||
try {
|
||||
return JSON.parse(global.localStorage.getItem(UKEY) || "[]");
|
||||
} catch (_e) {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
const saveU = (list) => {
|
||||
try {
|
||||
global.localStorage.setItem(UKEY, JSON.stringify(list));
|
||||
} catch (_e) {
|
||||
// 저장 공간 없음 — 이번 실행 동안만
|
||||
}
|
||||
};
|
||||
function openUser(ctx, api) {
|
||||
const list = loadU();
|
||||
const u = (ctx.uiState.usr = ctx.uiState.usr || { kw: "", sel: 0 });
|
||||
const shown = UA.userTextFind(
|
||||
list.map((x, i) => Object.assign({ i }, x)),
|
||||
u.kw,
|
||||
).map((x) => x.i);
|
||||
const all = u.kw ? shown : list.map((_x, i) => i);
|
||||
api.openModal(
|
||||
"사용자문자 편집 [wM_Edit_USER]",
|
||||
`<div class="stm-form"><div class="stm-row"><input id="us-kw" class="win-input" value="${esc(u.kw)}" placeholder="검색 명칭" style="width:160px;" />
|
||||
<button class="win-btn" id="us-add">항목추가</button><button class="win-btn" id="us-del">항목삭제</button><button class="win-btn" id="us-up">위로 이동</button><button class="win-btn" id="us-dn">아래로이동</button><button class="win-btn" id="us-init">초기화</button><button class="win-btn" id="us-sp">특수문자 불러오기</button></div>
|
||||
<div class="grid-scroll" style="max-height:46vh;"><table class="dbgrid"><thead><tr><th></th><th>검 색 명 칭</th><th>입 력 내 용</th><th>적용</th><th></th></tr></thead><tbody>${all
|
||||
.map(
|
||||
(i) =>
|
||||
`<tr class="${u.sel === i ? "boq-sel" : ""}"><td class="center"><input type="radio" name="us-r" class="us-r" data-i="${i}" ${u.sel === i ? "checked" : ""} /></td><td><input class="cell-input us-k" data-i="${i}" value="${esc(list[i].key)}" /></td><td><input class="cell-input us-t" data-i="${i}" value="${esc(list[i].text)}" style="width:320px;" /></td><td class="center"><input type="checkbox" class="us-on" data-i="${i}" ${list[i].on !== false ? "checked" : ""} /></td><td><button class="win-btn us-put" data-i="${i}">넣기</button></td></tr>`,
|
||||
)
|
||||
.join("")}</tbody></table></div><p class="stm-note">${esc(where())}</p></div>`,
|
||||
);
|
||||
const re = () => openUser(ctx, api);
|
||||
const save = () => (saveU(list), re());
|
||||
$("us-kw").onchange = (e) => ((u.kw = e.target.value), re());
|
||||
document
|
||||
.querySelectorAll(".us-r")
|
||||
.forEach((r) => (r.onchange = () => (u.sel = Number(r.getAttribute("data-i")))));
|
||||
document
|
||||
.querySelectorAll(".us-k")
|
||||
.forEach(
|
||||
(r) =>
|
||||
(r.onchange = () => (
|
||||
(list[Number(r.getAttribute("data-i"))].key = r.value),
|
||||
saveU(list)
|
||||
)),
|
||||
);
|
||||
document
|
||||
.querySelectorAll(".us-t")
|
||||
.forEach(
|
||||
(r) =>
|
||||
(r.onchange = () => (
|
||||
(list[Number(r.getAttribute("data-i"))].text = r.value),
|
||||
saveU(list)
|
||||
)),
|
||||
);
|
||||
document
|
||||
.querySelectorAll(".us-on")
|
||||
.forEach(
|
||||
(r) =>
|
||||
(r.onchange = () => (
|
||||
(list[Number(r.getAttribute("data-i"))].on = r.checked),
|
||||
saveU(list)
|
||||
)),
|
||||
);
|
||||
document
|
||||
.querySelectorAll(".us-put")
|
||||
.forEach(
|
||||
(b) =>
|
||||
(b.onclick = () => (
|
||||
insertText(list[Number(b.getAttribute("data-i"))].text || ""),
|
||||
api.closeModal()
|
||||
)),
|
||||
);
|
||||
$("us-add").onclick = () => (
|
||||
list.splice(u.sel + 1, 0, { key: "", text: "", on: true }),
|
||||
(u.sel += 1),
|
||||
save()
|
||||
);
|
||||
$("us-del").onclick = () => list[u.sel] && (list.splice(u.sel, 1), save());
|
||||
const mv = (d) => {
|
||||
const j = u.sel + d;
|
||||
if (!list[u.sel] || j < 0 || j >= list.length) return;
|
||||
[list[u.sel], list[j]] = [list[j], list[u.sel]];
|
||||
u.sel = j;
|
||||
save();
|
||||
};
|
||||
$("us-up").onclick = () => mv(-1);
|
||||
$("us-dn").onclick = () => mv(1);
|
||||
$("us-init").onclick = () => confirm("사용자문자 목록을 비움") && (list.splice(0), save());
|
||||
$("us-sp").onclick = () => openSpChar(ctx, api);
|
||||
}
|
||||
|
||||
// ── 내역 메모(DES1·DES2) ──
|
||||
const SC = () => global.STMATE_UI_BOQ_SCREEN;
|
||||
const selRow = (ctx) =>
|
||||
(ctx.dbfData.boq_rows || []).find((r) => Number(r._recno) === (ctx.uiState.boqSel || [])[0]);
|
||||
function openMemo(ctx, api) {
|
||||
const r = selRow(ctx);
|
||||
if (!r) return alert("메모를 넣을 내역 행을 고르세요");
|
||||
api.openModal(
|
||||
`메모 입력 [wInDlgMemo] — ${esc(r.ITNUM || "")} ${esc(r.NAME1 || r.BDCODE1 || "")}`,
|
||||
`<div class="stm-form"><textarea id="mm-t" class="win-input" style="width:520px;height:120px;">${esc([r.DES1, r.DES2].filter((x) => String(x || "").trim()).join("\n"))}</textarea>
|
||||
<div class="stm-row"><button class="win-btn primary" id="mm-ok">Ok</button><button class="win-btn" id="mm-no">Cancel</button><input type="file" id="mm-file" accept=".txt" style="display:none;" /><button class="win-btn" id="mm-load">Load</button><button class="win-btn" id="mm-clr">Clear</button></div>
|
||||
<p class="stm-note">줄 1 = 비고1(DES1) · 줄 2 = 비고2(DES2) — 내역 저장 때 함께 씀</p></div>`,
|
||||
);
|
||||
$("mm-no").onclick = api.closeModal;
|
||||
$("mm-clr").onclick = () => ($("mm-t").value = "");
|
||||
$("mm-load").onclick = () => $("mm-file").click();
|
||||
$("mm-file").onchange = (e) => {
|
||||
const f = e.target.files[0];
|
||||
if (f) f.text().then((t) => ($("mm-t").value = t));
|
||||
};
|
||||
$("mm-ok").onclick = () => {
|
||||
const [a, b] = $("mm-t").value.split(/\r?\n/);
|
||||
Object.assign(r, { DES1: (a || "").trim(), DES2: (b || "").trim() });
|
||||
api.closeModal();
|
||||
SC().after(ctx, api, { ok: true }, true);
|
||||
};
|
||||
}
|
||||
|
||||
// ── 동일코드 추려보기 ──
|
||||
function openSame(ctx, api) {
|
||||
const r = selRow(ctx);
|
||||
const c = r ? String(r.BDCODE1 || "").trim() : "";
|
||||
if (!c || /^(T\d|E\d|C\d|:)$/.test(c)) return alert("단가코드 행을 고르세요");
|
||||
const ev = global.STMATE_VIEW_PRJ_BOQ.evaluateFullBoqRows(ctx).filter(
|
||||
(x) => String(x.BDCODE1 || "").trim() === c,
|
||||
);
|
||||
api.openModal(
|
||||
`추려보기 [wSameCode] — ${c}`,
|
||||
`<div class="stm-form"><p><strong>${ev.length}번 사용중</strong> · Enter/더블클릭 → 내역서의 해당위치로 이동</p><div class="grid-scroll" style="max-height:50vh;"><table class="dbgrid"><thead><tr><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>${ev
|
||||
.map(
|
||||
(x) =>
|
||||
`<tr class="sm-go" data-r="${x._recno}" tabindex="0"><td>${esc(x.ITNUM)}</td><td>${esc(x.NAME1)}</td><td>${esc(x.SIZE1)}</td><td class="center">${esc(x.UNIT1)}</td><td class="num">${esc(x.QTY1)}</td><td class="num">${api.fmt(num(x.TAMT1))}</td><td class="num">${api.fmt(num(x.T_AMT1))}</td><td class="num">${api.fmt(num(x.L_AMT1))}</td><td class="num">${api.fmt(num(x.M_AMT1))}</td><td class="num">${api.fmt(num(x.S_AMT1))}</td><td>${esc(x.BDCODE1)}</td></tr>`,
|
||||
)
|
||||
.join("")}</tbody></table></div></div>`,
|
||||
);
|
||||
const go = (tr) => {
|
||||
const rec = Number(tr.getAttribute("data-r"));
|
||||
Object.assign(ctx.uiState, { boqSel: [rec], boqLevelMin: 0, boqScrollTo: rec });
|
||||
api.closeModal();
|
||||
api.renderWorkspace();
|
||||
};
|
||||
document
|
||||
.querySelectorAll(".sm-go")
|
||||
.forEach(
|
||||
(tr) => (
|
||||
(tr.ondblclick = () => go(tr)),
|
||||
(tr.onkeydown = (e) => e.key === "Enter" && go(tr))
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── 실적단가 → 일식단가 ──
|
||||
function openA2W(ctx, api) {
|
||||
if (!ctx.dbfData) return alert("열린 프로젝트가 없음");
|
||||
const src = ctx.dbfData.costa_rows || [];
|
||||
const A = global.STMATE_A2W;
|
||||
const w = (ctx.uiState.a2w = ctx.uiState.a2w || {
|
||||
sel: {},
|
||||
L: 100,
|
||||
M: 0,
|
||||
own: false,
|
||||
start: UC.nextCode(ctx.dbfData.costn_rows, "W"),
|
||||
});
|
||||
if (!src.length)
|
||||
return api.openModal(
|
||||
"실적단가를 일식단가로 변환등록 [wM_Conv_A2W]",
|
||||
'<p class="stm-warn" style="padding:12px;">이 프로젝트에 실적단가(COSTA.DBF) 자료가 없음</p>',
|
||||
);
|
||||
const ed = A ? A.normalizeEditor(w.L, w.M, "L") : { L: w.L, M: w.M, E: 100 - w.L - w.M };
|
||||
api.openModal(
|
||||
"실적단가를 일식단가로 변환등록 [wM_Conv_A2W]",
|
||||
`<div class="stm-form"><div class="grid-scroll" style="max-height:36vh;"><table class="dbgrid"><thead><tr><th>선택</th><th>코드</th><th>공종</th><th>명칭</th><th>규격</th><th>단위</th><th>단가</th><th>노무비율</th></tr></thead><tbody>${src
|
||||
.map(
|
||||
(r, i) =>
|
||||
`<tr><td class="center"><input type="checkbox" class="aw-sel" data-i="${i}" ${w.sel[i] ? "checked" : ""} /></td><td>${esc(r.CODE)}</td><td>${esc(r.CODEA || "")}</td><td>${esc(r.NAME)}</td><td>${esc(r.SIZE)}</td><td>${esc(r.UNIT)}</td><td class="num">${api.fmt(num(r.AAMT1))}</td><td class="num">${esc(r.RATE_L || "")}</td></tr>`,
|
||||
)
|
||||
.join("")}</tbody></table></div>
|
||||
<div class="stm-row"><label>비 목 율( 단가 => 노,재,경 ) 노무비율</label><input id="aw-l" type="number" class="win-input" value="${ed.L}" style="width:70px;" /><label>재료비율</label><input id="aw-m" type="number" class="win-input" value="${ed.M}" style="width:70px;" /><span>경 비 율 ${ed.E} %</span>
|
||||
<label><input type="checkbox" id="aw-own" ${w.own ? "checked" : ""} /> 【 노무비율ː개별노무비율 】【 나머지ː재료비우선 】</label></div>
|
||||
<div class="stm-row"><label>신규 시작코드</label><input id="aw-start" class="win-input" value="${esc(w.start)}" style="width:90px;" /><button class="win-btn primary" id="aw-ok">변환/생성</button></div></div>`,
|
||||
);
|
||||
document
|
||||
.querySelectorAll(".aw-sel")
|
||||
.forEach((i) => (i.onchange = () => (w.sel[i.getAttribute("data-i")] = i.checked)));
|
||||
$("aw-ok").onclick = () => {
|
||||
Object.assign(w, {
|
||||
L: num($("aw-l").value),
|
||||
M: num($("aw-m").value),
|
||||
own: $("aw-own").checked,
|
||||
start: $("aw-start").value.trim().toUpperCase(),
|
||||
});
|
||||
const rows = src.map((r, i) => ({ sel: Boolean(w.sel[i]), rateL: num(r.RATE_L) }));
|
||||
const rates = A
|
||||
? A.bulkApply(rows, { L: w.L, M: w.M, sender: "L" }, { individual: w.own })
|
||||
: rows;
|
||||
const pick = src.map((r, i) => Object.assign({}, r, rates[i])).filter((_r, i) => w.sel[i]);
|
||||
if (!pick.length) return alert("변환할 항목을 고르세요");
|
||||
const taken = new Set(ctx.dbfData.costn_rows.map(UC.codeOf));
|
||||
const made = UA.a2w(pick, { start: w.start, taken });
|
||||
const env = { d: ctx.dbfData, state: ctx.state, memo: ctx.uiState.editedSmtMap };
|
||||
made.forEach((m) =>
|
||||
UC.addCode(env, {
|
||||
kind: "W",
|
||||
code: m.CODE,
|
||||
fields: {
|
||||
NAME: m.NAME,
|
||||
SIZE: m.SIZE,
|
||||
UNIT: m.UNIT,
|
||||
MAMT1: String(m.M),
|
||||
LAMT1: String(m.L),
|
||||
SAMT1: String(m.S),
|
||||
TAMT1: String(m.T),
|
||||
},
|
||||
}),
|
||||
);
|
||||
ctx.uiState.danMsg = `일식단가(W) ${made.length}건 생성 ${made[0].CODE}~`;
|
||||
w.sel = {};
|
||||
api.closeModal();
|
||||
api.cascadeAndRender();
|
||||
};
|
||||
}
|
||||
|
||||
Object.assign(P.actions, {
|
||||
Act_UI_SpChar: (ctx, api) => openSpChar(ctx, api),
|
||||
Act_UI_UserText: (ctx, api) => openUser(ctx, api),
|
||||
Act_UI_A2W: (ctx, api) => openA2W(ctx, api),
|
||||
});
|
||||
P.menus.push(
|
||||
{ menu: "m4", act: "Act_UI_SpChar", label: "특수문자" },
|
||||
{ menu: "m4", act: "Act_UI_UserText", label: "사용자문자 편집" },
|
||||
{ menu: "m2", act: "Act_UI_A2W", label: "실적단가(A) ⇒ 일식단가(W) 변환" },
|
||||
);
|
||||
global.STMATE_UI_TEXT = { insertText, openSpChar, openUser, openMemo, openSame, openA2W };
|
||||
})(window);
|
||||
@@ -122,6 +122,42 @@ def _make_syn_resource(res_id: str, name: str, spec: str, unit: str, kind: str,
|
||||
}
|
||||
|
||||
|
||||
def pct_row_base(boq_rows: list[dict], i: int) -> dict:
|
||||
"""코드 붙은 % 줄(조달수수료 M%·T%) 밑수 — 같은 상위 공종 안 그 위 말단 줄들(닫힌 하위 공종 포함).
|
||||
|
||||
단가 칸 = 밑수 합 · 수량 칸 = 율(%) — 실무 봉화·울진·대흥 저장값 대조.
|
||||
"""
|
||||
ids, depth = [], []
|
||||
for j in range(i - 1, -1, -1):
|
||||
bd = (boq_rows[j].get("BDCODE1") or "").strip()
|
||||
if re.match(r"^E\d$", bd):
|
||||
depth.append(bd[1])
|
||||
elif re.match(r"^T\d$", bd):
|
||||
if depth and depth[-1] == bd[1]:
|
||||
depth.pop()
|
||||
else:
|
||||
break
|
||||
elif bd and not STRUCT_RE.match(bd):
|
||||
ids.append(f"BOQ_{j + 1}")
|
||||
unit = (boq_rows[i].get("UNIT1") or "").strip()
|
||||
return {"pctComp": unit[0] if unit[:1] in ("M", "L", "S", "T") else "T", "pctBase": ids[::-1]}
|
||||
|
||||
|
||||
def boq_fixed_unit(r: dict, crow: dict | None, is_pct: bool) -> dict:
|
||||
"""내역 줄 단가가 단가목록(COSTN 1칸)과 다르면 줄 고정 단가(fixedUnit) — 단가 끝전 보정 등 줄 단위 수정 보존.
|
||||
|
||||
0원 줄(변경 차수에서만 쓰는 신규 공종의 당초 칸)은 제외.
|
||||
"""
|
||||
if is_pct or not crow:
|
||||
return {}
|
||||
unit = {c: fnum(r.get(f"{c}AMT1")) for c in "MLS"}
|
||||
if not any(unit.values()) or all(
|
||||
abs(unit[c] - fnum(crow.get(f"{c}AMT1"))) < 1e-6 for c in "MLS"
|
||||
):
|
||||
return {}
|
||||
return {"fixedUnit": unit}
|
||||
|
||||
|
||||
def convert_dbf_to_sim_state(
|
||||
costn_rows: list[dict],
|
||||
boq_rows: list[dict],
|
||||
@@ -131,6 +167,7 @@ def convert_dbf_to_sim_state(
|
||||
) -> dict:
|
||||
"""원본 COSTN.DBF(전수) / COSTN.SMT / BOQ11~14.DBF(전수) / PRJLST.SMT를 무손실 변환."""
|
||||
costn_memos = costn_memos or {}
|
||||
costn_by_code = {(r.get("CODE") or "").strip(): r for r in costn_rows}
|
||||
resources: list[dict] = []
|
||||
machines: list[dict] = []
|
||||
san_items: list[dict] = []
|
||||
@@ -338,6 +375,8 @@ def convert_dbf_to_sim_state(
|
||||
"PRIN": r.get("PRIN", ""),
|
||||
"PROUT": r.get("PROUT", ""),
|
||||
"prQtys": [fnum(r.get(f"QTY{n}")) for n in range(1, 5)],
|
||||
**(pct_row_base(boq_rows, i) if is_pct_fee else {}),
|
||||
**boq_fixed_unit(r, costn_by_code.get(bd), is_pct_fee),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -95,6 +95,68 @@ def boq_spans(tbl: DbfTable) -> tuple[list[tuple[int, int, int, bool]], set[int]
|
||||
return spans, excl
|
||||
|
||||
|
||||
_PR_COMP = {"1": "M", "2": "L", "3": "S", "4": "T"}
|
||||
|
||||
|
||||
def _pct_rows(tbl: DbfTable, name: str, s: str, log: SaveLog) -> None:
|
||||
"""% 줄 다시 셈 — 단가 칸 = 밑수 · 금액 = 밑수 × 율(수량 칸, %) 원 미만 절사.
|
||||
|
||||
코드 붙은 줄(조달수수료 M%·T%) = 같은 상위 공종 안 앞 말단 줄 비목 합(server.pct_row_base)
|
||||
PR 줄 = 앞 PR 줄 뒤 표시(PRPT) 줄의 밑수 비목(PRIN) 합 → 대상 비목(PROUT) (calc.js prRow)
|
||||
"""
|
||||
if not all(f in tbl.fields for f in (f"QTY{s}", f"MAMT{s}", f"M_AMT{s}", "UNIT1")):
|
||||
return
|
||||
bds = [tbl.get(i, "BDCODE1") for i in range(len(tbl))]
|
||||
amt = lambda i, c: ( # noqa: E731
|
||||
sum(_dec(tbl.get(i, f"{k}_AMT{s}")) for k in "MLS")
|
||||
if c == "T"
|
||||
else _dec(tbl.get(i, f"{c}_AMT{s}"))
|
||||
)
|
||||
marked = {"M": Decimal(0), "L": Decimal(0), "S": Decimal(0)}
|
||||
for i in range(len(tbl)):
|
||||
bd, unit = bds[i], tbl.get(i, "UNIT1").strip()
|
||||
if not _leaf(bd):
|
||||
continue
|
||||
if bd == "PR" and "PRIN" in tbl.fields:
|
||||
bk, ok = (
|
||||
_PR_COMP.get(tbl.get(i, "PRIN").strip()),
|
||||
_PR_COMP.get(tbl.get(i, "PROUT").strip()),
|
||||
)
|
||||
if bk and ok:
|
||||
base = sum(marked.values()) if bk == "T" else marked[bk]
|
||||
_set_pct(tbl, name, i, s, "M" if ok == "T" else ok, base, log)
|
||||
marked = {k: Decimal(0) for k in marked}
|
||||
continue
|
||||
elif unit.endswith("%") and unit[:1] in "MLST":
|
||||
base, depth = Decimal(0), []
|
||||
for j in range(i - 1, -1, -1):
|
||||
b = bds[j]
|
||||
if re.match(r"^E\d$", b):
|
||||
depth.append(b[1])
|
||||
elif re.match(r"^T\d$", b):
|
||||
if depth and depth[-1] == b[1]:
|
||||
depth.pop()
|
||||
else:
|
||||
break
|
||||
elif _leaf(b):
|
||||
base += amt(j, unit[0])
|
||||
own = "L" if bd[:1] == "L" else "S" if bd[:1] == "S" else "M"
|
||||
_set_pct(tbl, name, i, s, own, base, log)
|
||||
continue
|
||||
if "PRPT" in tbl.fields and tbl.get(i, "PRPT").strip() not in ("", "0"):
|
||||
for k in marked:
|
||||
marked[k] += _dec(tbl.get(i, f"{k}_AMT{s}"))
|
||||
|
||||
|
||||
def _set_pct(tbl: DbfTable, name: str, i: int, s: str, own: str, base: Decimal, log: SaveLog):
|
||||
q = _dec(tbl.get(i, f"QTY{s}"))
|
||||
a = (base * q / 100).quantize(Decimal(1), rounding=ROUND_DOWN)
|
||||
for c in "MLS":
|
||||
log.set(tbl, name, i, f"{c}AMT{s}", base if c == own else 0)
|
||||
log.set(tbl, name, i, f"{c}_AMT{s}", a if c == own else 0)
|
||||
log.set(tbl, name, i, f"T_AMT{s}", a)
|
||||
|
||||
|
||||
def recompute_boq(tbl: DbfTable, name: str, slots: set[str], log: SaveLog) -> dict[str, list]:
|
||||
"""바뀐 차수의 소계 행 다시 셈 · 차수별 직접비(재·노·경·계) 반환."""
|
||||
spans, excl = boq_spans(tbl)
|
||||
@@ -104,6 +166,7 @@ def recompute_boq(tbl: DbfTable, name: str, slots: set[str], log: SaveLog) -> di
|
||||
cols = [f"{c}_AMT{s}" for c in "MLST"]
|
||||
if not all(c in tbl.fields for c in cols):
|
||||
continue
|
||||
_pct_rows(tbl, name, s, log)
|
||||
vals = [[_dec(tbl.get(i, c)) for c in cols] for i in range(len(tbl))]
|
||||
for i, a, b, isx in spans:
|
||||
tot = [Decimal(0)] * 4
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
const base = String(payload.target_folder_name || "stmate").slice(0, 20);
|
||||
payload.target_folder_name = `${base}_백업_${kind === "day" ? today() : nowTag()}`;
|
||||
try {
|
||||
const res = await API.postJson("/api/save", payload);
|
||||
const res = await API.postSave(payload);
|
||||
if (kind === "day") prefs.lastBackDay = today();
|
||||
if (kind === "30") prefs.lastBack30 = Date.now();
|
||||
savePrefs();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* STMATE w 8.0 — 내역서 도구 대화상자 순수 계산 (ui_boq_tools.js) · wITNum · wExpnd · wM_Rate · wBoqRate
|
||||
* STMATE w 8.0 — 내역서 도구 대화상자 순수 계산 (ui_boq_tools.js) · wITNum · wExpnd(wM_Expnd·wM_Expnd10) · wM_Rate · wBoqRate
|
||||
* 근거: 지식DB STmate/old/30_원자료/ui_form_catalog.txt
|
||||
* wITNum RdoFirstChar(공란/./(/-/추가입력) · RdoLastChar(공란/./)) · CBoxType(가..하 / a..z) · CBoxLeft(여백 0~7)
|
||||
* Fmt_Typ(0/00/000) · cBx_itNum_B(직전공종 번호 / 길이만큼 여백) · cBx_OnlyIT(대상) · cBx_Bl_Lev(초기화 레벨)
|
||||
@@ -97,18 +97,25 @@
|
||||
}
|
||||
|
||||
/**
|
||||
* 일위대가 풀기(wExpnd) — B 코드 행 하나를 구성 자원 행으로 바꾸고 T{n}/E{n} 로 감쌈
|
||||
* 구성 수량 = 구성표 수량 × 내역 수량 → 절삭/반올림 소수 d 위 · 비율(PR)·% 단위·구분(T/C) 줄은 풀지 않고 개수만 알림
|
||||
* 일위대가 풀기(wExpnd · wM_Expnd · wM_Expnd10) — B 코드 행 하나를 구성 자원 행으로 바꾸고 T{n}/E{n} 로 감쌈
|
||||
* 구성 수량 = 구성표 수량 × 할증(노무·재료) × 내역 수량 → 비목별(M·L·X·기타 W) 절삭/반올림 소수 d 위
|
||||
* o = { level(0 = 입력안함 · 감싸지 않음), mode, digits, per: { M|L|X|W: { mode, digits } }, hal: { L, M },
|
||||
* keepName(공종의 명칭/규격 입력), adds: [{ pct, prin, prout, name }](손료계산 추가입력 → PR 비율 줄) }
|
||||
* 비율(PR)·% 단위·구분(T/C) 구성 줄은 풀지 않고 개수만 알림 · 추가 줄의 밑수 = 풀린 줄(PRPT 표시)
|
||||
*/
|
||||
function expandIlwi(model, recno, bdqtyRows, costnByCode, opts, ini) {
|
||||
const o = Object.assign({ level: 1, mode: "trunc", digits: 4 }, opts || {});
|
||||
const o = Object.assign(
|
||||
{ level: 1, mode: "trunc", digits: 4, per: {}, hal: {}, keepName: true, adds: [] },
|
||||
opts || {},
|
||||
);
|
||||
const row = model.rows.find((x) => num(x._recno) === num(recno));
|
||||
if (!row || !isLeaf(row)) return { ok: false, error: "풀 일위대가 행을 고르세요" };
|
||||
const code = codeOf(row);
|
||||
if (code[0] !== "B") return { ok: false, error: `일위대가(B) 코드만 풀 수 있음 — ${code}` };
|
||||
const comps = (bdqtyRows || []).filter((r) => String(r.BDCODE || "").trim() === code);
|
||||
if (!comps.length) return { ok: false, error: `${code} 구성표(BDQTY) 없음` };
|
||||
const Q = num(row.QTY1);
|
||||
const Q = num(row.QTY1) || 1; // 실행수량 0 이면 1(wM_Expnd 안내)
|
||||
const adds = (o.adds || []).filter((a) => num(a.pct) && a.prin && a.prout);
|
||||
const made = [];
|
||||
let skipped = 0;
|
||||
comps.forEach((c) => {
|
||||
@@ -116,36 +123,70 @@
|
||||
cr = costnByCode[cc];
|
||||
const unit = cr ? String(cr.UNIT || "").trim() : "";
|
||||
if (!cr || cc === "PR" || /^[TC]\d$/.test(cc) || /%$/.test(unit)) return void (skipped += 1);
|
||||
const k = "MLX".includes(cc[0]) ? cc[0] : "W";
|
||||
const r = Object.assign({ mode: o.mode, digits: o.digits }, (o.per || {})[k] || {});
|
||||
const h = num((o.hal || {})[cc[0]]) || 1;
|
||||
made.push({
|
||||
code: cc,
|
||||
name: cr.NAME || "",
|
||||
spec: cr.SIZE || "",
|
||||
unit,
|
||||
qty: roundTo(num(c.QTY1) * Q, o.digits, o.mode),
|
||||
qty: roundTo(num(c.QTY1) * h * Q, r.digits, r.mode),
|
||||
});
|
||||
});
|
||||
if (!made.length) return { ok: false, error: "풀 수 있는 구성 자원 없음" };
|
||||
const at = model.rows.indexOf(row);
|
||||
const head = B.makeRow(model, {
|
||||
BDCODE1: `T${o.level}`,
|
||||
NAME1: row.NAME1 || "",
|
||||
SIZE1: row.SIZE1 || "",
|
||||
});
|
||||
head.ITNUM = row.ITNUM || "";
|
||||
let rec = num(head._recno);
|
||||
const wrap = Number(o.level) > 0;
|
||||
const head = wrap
|
||||
? B.makeRow(model, {
|
||||
BDCODE1: `T${o.level}`,
|
||||
NAME1: o.keepName ? row.NAME1 || "" : "",
|
||||
SIZE1: o.keepName ? row.SIZE1 || "" : "",
|
||||
})
|
||||
: null;
|
||||
if (head) head.ITNUM = row.ITNUM || "";
|
||||
let rec = head ? num(head._recno) : B.nextRecno(model) + 1;
|
||||
const body = made.map((m) => {
|
||||
const r = B.makeRow(model, { BDCODE1: m.code, NAME1: m.name, SIZE1: m.spec, UNIT1: m.unit });
|
||||
Object.assign(r, { QTY1: String(m.qty), _recno: (rec -= 1) });
|
||||
if (adds.length) r.PRPT = "1";
|
||||
return r;
|
||||
});
|
||||
const tail = B.makeRow(model, { BDCODE1: `E${o.level}`, ITNUM: B.dispName(ini, o.level) });
|
||||
tail._recno = rec - 1;
|
||||
const res = B.edit(model, (rows, links) => {
|
||||
links.set(head, tail).set(tail, head);
|
||||
rows.splice(at, 1, head, ...body, tail);
|
||||
if (!row._new) model.deleted.push(num(row._recno));
|
||||
return { count: body.length, skipped };
|
||||
const prs = adds.map((a) => {
|
||||
const r = B.makeRow(model, { BDCODE1: "PR", NAME1: a.name || "손료", UNIT1: "%" });
|
||||
Object.assign(r, {
|
||||
QTY1: String(num(a.pct)),
|
||||
PRIN: String(a.prin),
|
||||
PROUT: String(a.prout),
|
||||
_recno: (rec -= 1),
|
||||
});
|
||||
return r;
|
||||
});
|
||||
const tail = wrap
|
||||
? B.makeRow(model, { BDCODE1: `E${o.level}`, ITNUM: B.dispName(ini, o.level) })
|
||||
: null;
|
||||
if (tail) tail._recno = rec - 1;
|
||||
const res = B.edit(model, (rows, links) => {
|
||||
if (wrap) links.set(head, tail).set(tail, head);
|
||||
rows.splice(at, 1, ...(wrap ? [head] : []), ...body, ...prs, ...(wrap ? [tail] : []));
|
||||
if (!row._new) model.deleted.push(num(row._recno));
|
||||
return { count: body.length + prs.length, skipped };
|
||||
});
|
||||
// 계산 원천 — 비율 줄·밑수 표시(calc.js prRow · PRPT)
|
||||
if (res.ok && adds.length) {
|
||||
const byRec = new Map((model.items || []).map((it) => [num(it._recno), it]));
|
||||
body.forEach((r) => byRec.get(num(r._recno)) && (byRec.get(num(r._recno)).PRPT = "1"));
|
||||
prs.forEach((r) => {
|
||||
const it = byRec.get(num(r._recno));
|
||||
if (it)
|
||||
Object.assign(it, {
|
||||
PRIN: r.PRIN,
|
||||
PROUT: r.PROUT,
|
||||
PRPT: "0",
|
||||
prQtys: [num(r.QTY1), 0, 0, 0],
|
||||
});
|
||||
});
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -164,6 +205,36 @@
|
||||
return { changed };
|
||||
}
|
||||
|
||||
/**
|
||||
* 비목별 단가 끝전 보정(wM_Rate wcBx_Pnt1_M·_L … · rd_Amt1/rd_Amt2 범위) — 지금 행 단가(재·노·경)를 비목마다 절사/반올림
|
||||
* units = { recno: { M, L, S } }(계산 결과 행 단가) · 계산 원천 항목 fixedUnit · 저장 원천 MAMT1·LAMT1·SAMT1·TAMT1
|
||||
*/
|
||||
function roundUnits(model, recnos, units, per) {
|
||||
const set = recnos ? new Set(recnos.map(num)) : null;
|
||||
const byRec = new Map(model.items.map((it) => [num(it._recno), it]));
|
||||
let changed = 0;
|
||||
model.rows.forEach((r) => {
|
||||
const u = (units || {})[num(r._recno)];
|
||||
if (!isLeaf(r) || !u || (set && !set.has(num(r._recno)))) return;
|
||||
const v = {};
|
||||
["M", "L", "S"].forEach((c) => {
|
||||
const o = (per || {})[c];
|
||||
v[c] = o ? roundTo(num(u[c]), o.digits, o.mode) : num(u[c]);
|
||||
});
|
||||
if (["M", "L", "S"].every((c) => v[c] === num(u[c]))) return;
|
||||
Object.assign(r, {
|
||||
MAMT1: String(v.M),
|
||||
LAMT1: String(v.L),
|
||||
SAMT1: String(v.S),
|
||||
TAMT1: String(v.M + v.L + v.S),
|
||||
});
|
||||
const it = byRec.get(num(r._recno));
|
||||
if (it) it.fixedUnit = v;
|
||||
changed += 1;
|
||||
});
|
||||
return { changed };
|
||||
}
|
||||
|
||||
// 할증율 적용(wBoqRate) — 행 칸 RATE(전체) 또는 RM·RL·RS(비목별) · 계산 원천 항목에도 halRate 로 둠
|
||||
function applyHalRate(model, recnos, rates, mode) {
|
||||
const set = recnos ? new Set(recnos.map(num)) : null;
|
||||
@@ -188,6 +259,14 @@
|
||||
return { changed };
|
||||
}
|
||||
|
||||
global.STMATE_UI_BOQ_TOOLS = { seqText, autoItnum, roundTo, expandIlwi, roundQty, applyHalRate };
|
||||
global.STMATE_UI_BOQ_TOOLS = {
|
||||
seqText,
|
||||
autoItnum,
|
||||
roundTo,
|
||||
expandIlwi,
|
||||
roundQty,
|
||||
roundUnits,
|
||||
applyHalRate,
|
||||
};
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = global.STMATE_UI_BOQ_TOOLS;
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
||||
@@ -0,0 +1,494 @@
|
||||
/**
|
||||
* STMATE w 8.0 — 코드 관리 순수 계산 (ui_code.js) · 신규등록 · 삭제 · 코드변경 · +α · 항목교체 · 동일항목
|
||||
* 근거: DFM 원문(C:/경영정보(주)/System/ST_CPM) — ST_U_ChgCode(uM_ChgCode 현재코드→바뀔코드 · uM_Chg_Plus 현재최종+α
|
||||
* · uM_ReplCode 현재코드→대상코드 · 동일항목(명칭, 규격... 단가) 대상코드 자동입력) · ST_U_DelCode(uM_DelCode 선택 ·
|
||||
* 코드일괄삭제(중기비제외/포함) · 사용안된 "천원" "인" 일위대가 · *[USE_] 사용표시) · STmate.Cpm wM_Boxa
|
||||
* Act_CopyNew(선택된 코드를 복사하여 새로운코드 생성)
|
||||
* env = { d: dbfData, state: sim_state, memo: uiState.editedSmtMap } — 저장 원천(d)과 계산 원천(state)을 함께 고침
|
||||
* 저장 표시: d.ui_dirty · 새 행 _new(_from = 복사원 _recno) · 지운 원본 d.costn_deleted · 다른 표 코드 r._bd0
|
||||
* 구성표는 원래 행 그대로 바꿔 둠 — 저장은 기본 묶음의 bdqty_rows(stc_save_extra.save_bdqty)
|
||||
*/
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
const CODE_RE = /^[A-Z]\d{5}$/;
|
||||
const KIND_NAME = { L: "노무비", M: "재료비", S: "경비", B: "일위대가", X: "중기비" };
|
||||
Object.assign(KIND_NAME, { D: "단가산출", W: "견적단가", I: "그림코드", A: "실적단가" });
|
||||
const txt = (v) => String(v == null ? "" : v).trim();
|
||||
const num = (v) => {
|
||||
const n = parseFloat(txt(v).replace(/,/g, ""));
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
};
|
||||
const codeOf = (r) => txt(r && r.CODE);
|
||||
const BDK = ["BDCODE1", "BDCODE2", "BDCODE3", "BDCODE4"];
|
||||
|
||||
// 내역 표 행 — 열린 표(boq_rows)와 표 묶음(boq_tables)이 같은 배열이 아닐 때도 한 번씩
|
||||
function boqRowSets(d) {
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
const add = (table, rows) => {
|
||||
if (!rows || seen.has(rows)) return;
|
||||
seen.add(rows);
|
||||
out.push([table, rows]);
|
||||
};
|
||||
add(d.boq_table_name || "", d.boq_rows);
|
||||
Object.entries(d.boq_tables || {}).forEach(([t, rows]) => add(t, rows));
|
||||
return out;
|
||||
}
|
||||
|
||||
const memoText = (env, c) => {
|
||||
const m = env.memo || {};
|
||||
if (Object.prototype.hasOwnProperty.call(m, c)) return m[c] || "";
|
||||
const x = ((env.d && env.d.costn_memos) || {})[c];
|
||||
return (x && x.text) || "";
|
||||
};
|
||||
const REF_RE = /&([A-Z]\d{5})([LMS]?(?::[LMS])?)&/g;
|
||||
const memoRefs = (t) => [...String(t || "").matchAll(REF_RE)].map((m) => m[1]);
|
||||
|
||||
/** 사용표시(*) — 내역서(모든 표 · 1~4차 코드) · 구성표(BDQTY) · 산출근거 식 참조 */
|
||||
function usedSet(env) {
|
||||
const d = env.d,
|
||||
used = new Set();
|
||||
boqRowSets(d).forEach(([, rows]) =>
|
||||
rows.forEach((r) => BDK.forEach((k) => txt(r[k]) && used.add(txt(r[k])))),
|
||||
);
|
||||
(d.bdqty_rows || []).forEach((r) => txt(r.CODE) && used.add(txt(r.CODE)));
|
||||
(d.costn_rows || []).forEach((r) =>
|
||||
memoRefs(memoText(env, codeOf(r))).forEach((c) => used.add(c)),
|
||||
);
|
||||
return used;
|
||||
}
|
||||
|
||||
// 현재최종 — 종류(첫 글자)별 가장 큰 코드
|
||||
function lastCodes(rows) {
|
||||
const out = {};
|
||||
(rows || []).forEach((r) => {
|
||||
const c = codeOf(r);
|
||||
if (CODE_RE.test(c) && (!out[c[0]] || c > out[c[0]])) out[c[0]] = c;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
const codeNum = (c) => parseInt(String(c).slice(1), 10) || 0;
|
||||
const mkCode = (k, n) => `${k}${String(n).padStart(5, "0")}`;
|
||||
function nextCode(rows, kind, taken) {
|
||||
const last = lastCodes(rows)[kind];
|
||||
let n = last ? codeNum(last) + 1 : 1;
|
||||
while (taken && taken.has(mkCode(kind, n))) n += 1;
|
||||
return n > 99999 ? "" : mkCode(kind, n);
|
||||
}
|
||||
|
||||
/** 동일항목 — 명칭+규격+단위+단가(계) 같은 종류 안 묶음 · 첫 코드(작은 번호)가 대표 */
|
||||
function sameItems(rows, kinds) {
|
||||
const g = new Map();
|
||||
(rows || []).forEach((r) => {
|
||||
const c = codeOf(r);
|
||||
if (!CODE_RE.test(c) || (kinds && !kinds.includes(c[0]))) return;
|
||||
const k = [c[0], txt(r.NAME), txt(r.SIZE), txt(r.UNIT), num(r.TAMT1)].join("\u0001");
|
||||
g.set(k, (g.get(k) || []).concat(c));
|
||||
});
|
||||
return [...g.values()].filter((a) => a.length > 1).map((a) => a.slice().sort());
|
||||
}
|
||||
|
||||
// ── 계산 원천(state) 코드 바꿈 — id·code·refId·machineId · 합성 자원 「코드_M/L/S」 ──
|
||||
function stateRename(state, map) {
|
||||
if (!state) return;
|
||||
const sw = (v) => {
|
||||
if (typeof v !== "string") return v;
|
||||
if (map[v]) return map[v];
|
||||
const m = v.match(/^([A-Z]\d{5})_([MLS])$/);
|
||||
return m && map[m[1]] ? `${map[m[1]]}_${m[2]}` : v;
|
||||
};
|
||||
const fix = (o) =>
|
||||
["id", "code", "refId", "machineId"].forEach((k) => k in o && (o[k] = sw(o[k])));
|
||||
["resources", "machines", "sanItems", "ilwiItems", "boqItems"].forEach((k) =>
|
||||
(state[k] || []).forEach((o) => {
|
||||
fix(o);
|
||||
(o.lines || []).forEach(fix);
|
||||
(o.smtRefs || []).forEach(fix);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// 참조만 바꿈(내역 · 구성표 · 식) — 항목교체·코드변경 공통
|
||||
function remapRefs(env, map, opt) {
|
||||
const d = env.d;
|
||||
const o = Object.assign({ boq: true, comp: true, memo: true, items: true }, opt || {});
|
||||
const n = { boq: 0, comp: 0, memo: 0 };
|
||||
if (o.boq)
|
||||
boqRowSets(d).forEach(([, rows]) =>
|
||||
rows.forEach((r) =>
|
||||
BDK.forEach((k) => {
|
||||
const c = txt(r[k]);
|
||||
if (!map[c]) return;
|
||||
if (!r._bd0) r._bd0 = Object.fromEntries(BDK.map((x) => [x, r[x]]));
|
||||
r[k] = map[c];
|
||||
n.boq += 1;
|
||||
}),
|
||||
),
|
||||
);
|
||||
if (o.comp)
|
||||
(d.bdqty_rows || []).forEach((r) => {
|
||||
const c = txt(r.CODE);
|
||||
if (map[c]) ((r.CODE = map[c]), (n.comp += 1));
|
||||
});
|
||||
if (o.memo) {
|
||||
env.memo = env.memo || {};
|
||||
(d.costn_rows || []).forEach((r) => {
|
||||
const c = codeOf(r),
|
||||
t = memoText(env, c);
|
||||
if (!t) return;
|
||||
const t2 = t.replace(REF_RE, (all, code, tail) =>
|
||||
map[code] ? `&${map[code]}${tail}&` : all,
|
||||
);
|
||||
if (t2 !== t) ((env.memo[c] = t2), (n.memo += 1));
|
||||
});
|
||||
}
|
||||
(env.state && env.state.boqItems ? env.state.boqItems : []).forEach((it) => {
|
||||
if (o.boq && o.items && map[it.code])
|
||||
Object.assign(it, { code: map[it.code], refId: map[it.code] });
|
||||
});
|
||||
d.ui_dirty = true;
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
* 코드변경 검사 — 바뀔코드 = 같은 종류 글자 + 5자리 · 묶음 안 겹침 없음 · 남는 코드와 겹침 없음
|
||||
* map = { 현재코드: 바뀔코드 } (빈 칸·같은 코드는 뺌)
|
||||
*/
|
||||
function checkChange(rows, map) {
|
||||
const errs = [];
|
||||
const all = new Set((rows || []).map(codeOf));
|
||||
const pairs = Object.entries(map || {}).filter(([a, b]) => txt(b) && txt(a) !== txt(b));
|
||||
const moving = new Set(pairs.map(([a]) => a));
|
||||
const target = new Map();
|
||||
pairs.forEach(([a, b]) => {
|
||||
if (!all.has(a)) errs.push(`${a}: 없는 코드`);
|
||||
if (!CODE_RE.test(b)) errs.push(`${a} → ${b}: 코드 꼴(글자+5자리) 아님`);
|
||||
else if (b[0] !== a[0])
|
||||
errs.push(`${a} → ${b}: 종류(${KIND_NAME[a[0]] || a[0]}) 글자가 다름`);
|
||||
if (target.has(b)) errs.push(`${b}: ${target.get(b)}·${a} 둘이 같은 코드로`);
|
||||
target.set(b, a);
|
||||
if (all.has(b) && !moving.has(b))
|
||||
errs.push(`${b}: 이미 있는 코드(항목교체·코드병합을 쓰세요)`);
|
||||
});
|
||||
return { pairs, errs };
|
||||
}
|
||||
|
||||
/** 코드변경 실행(Act_Exc_Change) — COSTN 코드 · 메모 열쇠 · 모든 참조 · 계산 원천 */
|
||||
function changeCodes(env, map) {
|
||||
const d = env.d;
|
||||
const chk = checkChange(d.costn_rows, map);
|
||||
if (chk.errs.length) return { ok: false, errors: chk.errs };
|
||||
const m = Object.fromEntries(chk.pairs);
|
||||
if (!chk.pairs.length) return { ok: false, errors: ["바뀔코드가 없음"] };
|
||||
(d.costn_rows || []).forEach((r) => {
|
||||
const c = codeOf(r);
|
||||
if (m[c]) r.CODE = m[c];
|
||||
});
|
||||
(d.bdqty_rows || []).forEach((r) => {
|
||||
const c = txt(r.BDCODE);
|
||||
if (m[c]) r.BDCODE = m[c];
|
||||
});
|
||||
const memos = d.costn_memos || {},
|
||||
edits = (env.memo = env.memo || {});
|
||||
const oldM = Object.assign({}, memos),
|
||||
oldE = Object.assign({}, edits);
|
||||
chk.pairs.forEach(([a]) => (delete memos[a], delete edits[a]));
|
||||
chk.pairs.forEach(([a, b]) => {
|
||||
if (oldM[a]) memos[b] = Object.assign({}, oldM[a], { code: b });
|
||||
if (a in oldE) edits[b] = oldE[a];
|
||||
});
|
||||
const n = remapRefs(env, m, { items: false }); // 계산 원천은 아래 한 번에(연쇄 +α 겹침 방지)
|
||||
stateRename(env.state, m);
|
||||
return { ok: true, count: chk.pairs.length, refs: n, map: m };
|
||||
}
|
||||
|
||||
/** 코드번호 변경(현재코드 + α) — 종류별 α 만큼 모든 코드를 밀어 냄 */
|
||||
function plusMap(rows, alpha) {
|
||||
const map = {};
|
||||
(rows || []).forEach((r) => {
|
||||
const c = codeOf(r),
|
||||
a = parseInt((alpha || {})[c[0]], 10) || 0;
|
||||
if (CODE_RE.test(c) && a) map[c] = mkCode(c[0], codeNum(c) + a);
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
/** 항목교체(Act_Exc_Replace) — 현재코드를 쓰던 자리(내역·구성·식)를 대상코드로 · 현재코드는 남음 */
|
||||
function replaceCodes(env, map) {
|
||||
const d = env.d;
|
||||
const all = new Set((d.costn_rows || []).map(codeOf));
|
||||
const errs = [];
|
||||
const pairs = Object.entries(map || {}).filter(([a, b]) => txt(b) && a !== txt(b));
|
||||
pairs.forEach(([a, b]) => {
|
||||
if (!all.has(b)) errs.push(`${a} → ${b}: 대상코드가 단가목록에 없음`);
|
||||
if (map[b] && txt(map[b]) !== b) errs.push(`${b}: 대상코드가 다시 교체됨`);
|
||||
});
|
||||
if (errs.length) return { ok: false, errors: errs };
|
||||
if (!pairs.length) return { ok: false, errors: ["대상코드가 없음"] };
|
||||
const m = Object.fromEntries(pairs);
|
||||
const n = remapRefs(env, m);
|
||||
// 계산 원천 — 내역 항목만 대상 단가로(단가 원천 자원은 그대로)
|
||||
return { ok: true, count: pairs.length, refs: n, map: m };
|
||||
}
|
||||
|
||||
/** 동일항목 대상코드 자동입력 — 묶음의 대표(첫 코드)로 */
|
||||
function autoReplaceMap(rows, kinds) {
|
||||
const map = {};
|
||||
sameItems(rows, kinds).forEach((g) => g.slice(1).forEach((c) => (map[c] = g[0])));
|
||||
return map;
|
||||
}
|
||||
|
||||
// ── 지우기 ──
|
||||
/** 코드일괄삭제 대상 — 사용표시 없는 코드(중기비 포함 여부) */
|
||||
function unusedCodes(env, withX) {
|
||||
const used = usedSet(env);
|
||||
return (env.d.costn_rows || [])
|
||||
.map(codeOf)
|
||||
.filter((c) => CODE_RE.test(c) && !used.has(c) && (withX || c[0] !== "X"));
|
||||
}
|
||||
// 사용안된 "천원" "인" 일위대가
|
||||
const unusedBUnit = (env) => {
|
||||
const used = usedSet(env);
|
||||
return (env.d.costn_rows || [])
|
||||
.filter(
|
||||
(r) => codeOf(r)[0] === "B" && !used.has(codeOf(r)) && /^(천원|인)$/.test(txt(r.UNIT)),
|
||||
)
|
||||
.map(codeOf);
|
||||
};
|
||||
|
||||
/** 선택코드 삭제실행 — 쓰이는 코드는 지우지 않음(원본 Chk_True_Del_1 자리) */
|
||||
function deleteCodes(env, codes) {
|
||||
const d = env.d;
|
||||
const want = new Set((codes || []).map(txt));
|
||||
const used = usedSet(env);
|
||||
const kept = [...want].filter((c) => used.has(c));
|
||||
const kill = new Set([...want].filter((c) => !used.has(c)));
|
||||
if (!kill.size)
|
||||
return {
|
||||
ok: false,
|
||||
errors: kept.length ? [`쓰이는 코드라 못 지움: ${kept.join(", ")}`] : ["지울 코드 없음"],
|
||||
};
|
||||
d.costn_deleted = d.costn_deleted || [];
|
||||
d.costn_rows = (d.costn_rows || []).filter((r) => {
|
||||
if (!kill.has(codeOf(r))) return true;
|
||||
if (!r._new && r._recno != null) d.costn_deleted.push(Number(r._recno));
|
||||
return false;
|
||||
});
|
||||
d.bdqty_rows = (d.bdqty_rows || []).filter((r) => !kill.has(txt(r.BDCODE)));
|
||||
kill.forEach((c) => {
|
||||
if (d.costn_memos) delete d.costn_memos[c];
|
||||
if (env.memo) delete env.memo[c];
|
||||
});
|
||||
const st = env.state;
|
||||
if (st) {
|
||||
const gone = (o) => kill.has(o.id) || kill.has(String(o.id || "").replace(/_[MLS]$/, ""));
|
||||
["resources", "machines", "sanItems", "ilwiItems"].forEach(
|
||||
(k) => st[k] && (st[k] = st[k].filter((o) => !gone(o))),
|
||||
);
|
||||
}
|
||||
d.costn_count = d.costn_rows.length;
|
||||
d.ui_dirty = true;
|
||||
return { ok: true, count: kill.size, deleted: [...kill], kept };
|
||||
}
|
||||
|
||||
// ── 신규등록 ──
|
||||
function blankCostn(tmpl) {
|
||||
const row = {};
|
||||
Object.keys(tmpl || {}).forEach((k) => {
|
||||
if (k.startsWith("_") || k.endsWith("_HEX") || k === "IFORM") return;
|
||||
row[k] = /AMT|^PG_\d$|NAGCD|^RXX$/.test(k) ? "0" : "";
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
// 계산 원천 항목 하나 — 서버 convert_dbf_to_sim_state 와 같은 꼴(기초단가 자원 · 그 밖 일위대가류)
|
||||
function stateEntry(state, r) {
|
||||
const c = codeOf(r),
|
||||
k = c[0];
|
||||
const name = txt(r.NAME) || c,
|
||||
spec = txt(r.SIZE),
|
||||
unit = txt(r.UNIT);
|
||||
const t = num(r.TAMT1);
|
||||
if ("MLS".includes(k)) {
|
||||
const cands = [1, 2, 3, 4, 5].map((n) => num(r[`AMT_${n}1`]));
|
||||
state.resources.push({
|
||||
id: c,
|
||||
code: c,
|
||||
name,
|
||||
spec,
|
||||
unit,
|
||||
kind: k === "L" ? "노무비" : k === "S" ? "경비" : "재료비",
|
||||
candidates: cands.some(Boolean) ? cands : [t, 0, 0, 0, 0],
|
||||
adoptMode: "manual",
|
||||
manualPrice: t,
|
||||
convertHourly: false,
|
||||
isUsd: false,
|
||||
jdCode: txt(r.JDCODE),
|
||||
uc: txt(r.UC) || "F",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const lines = [];
|
||||
[
|
||||
["M", "재료비", num(r.MAMT1)],
|
||||
["L", "노무비", num(r.LAMT1)],
|
||||
["S", "경비", num(r.SAMT1)],
|
||||
].forEach(([tag, kind, v]) => {
|
||||
if (!(v > 0)) return;
|
||||
const id = `${c}_${tag}`;
|
||||
state.resources.push({
|
||||
id,
|
||||
code: id,
|
||||
name: `${name} (${kind})`,
|
||||
spec,
|
||||
unit,
|
||||
kind,
|
||||
candidates: [v, 0, 0, 0, 0],
|
||||
adoptMode: "manual",
|
||||
manualPrice: v,
|
||||
convertHourly: false,
|
||||
isUsd: false,
|
||||
isSynthetic: true,
|
||||
});
|
||||
lines.push({ refId: id, qty: 1.0, halPercent: 0, devd: false });
|
||||
});
|
||||
const base = { id: c, code: c, spec, unit, fixedM: num(r.MAMT1), fixedL: num(r.LAMT1) };
|
||||
Object.assign(base, { fixedS: num(r.SAMT1), fixedT: t });
|
||||
state.ilwiItems.push(
|
||||
Object.assign({ name: `[${k}] ${name}`, rawName: name, prefix: k, lines }, base),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 신규등록(Act_CopyNew) — 선택 코드를 복사해 새 코드 · 구성표·산출근거 식도 복사
|
||||
* from 없으면 빈 행(종류 kind) · fields = { NAME, SIZE, UNIT, … } 덮어씀
|
||||
*/
|
||||
function addCode(env, o) {
|
||||
const d = env.d;
|
||||
const rows = (d.costn_rows = d.costn_rows || []);
|
||||
const src = o.from ? rows.find((r) => codeOf(r) === o.from) : null;
|
||||
if (o.from && !src) return { ok: false, errors: [`복사할 코드 없음: ${o.from}`] };
|
||||
const kind = src ? codeOf(src)[0] : txt(o.kind).toUpperCase();
|
||||
if (!KIND_NAME[kind]) return { ok: false, errors: [`코드 종류 글자 아님: ${kind}`] };
|
||||
const all = new Set(rows.map(codeOf));
|
||||
const code = txt(o.code) || nextCode(rows, kind, all);
|
||||
if (!CODE_RE.test(code) || code[0] !== kind)
|
||||
return { ok: false, errors: [`코드 꼴 아님: ${code}`] };
|
||||
if (all.has(code)) return { ok: false, errors: [`이미 있는 코드: ${code}`] };
|
||||
const row = src
|
||||
? Object.fromEntries(
|
||||
Object.entries(src).filter(
|
||||
([k]) => !k.startsWith("_") && !k.endsWith("_HEX") && k !== "IFORM",
|
||||
),
|
||||
)
|
||||
: blankCostn(rows[0]);
|
||||
Object.assign(row, o.fields || {}, { CODE: code, _new: true, _deleted: false });
|
||||
if (src && src._recno != null) row._from = src._new ? src._from : src._recno;
|
||||
const at = src ? rows.indexOf(src) + 1 : rows.length;
|
||||
rows.splice(at, 0, row);
|
||||
if (src) {
|
||||
const comps = (d.bdqty_rows || []).filter((r) => txt(r.BDCODE) === codeOf(src));
|
||||
if (comps.length) {
|
||||
const all2 = d.bdqty_rows;
|
||||
const last = all2.lastIndexOf(comps[comps.length - 1]);
|
||||
const copies = comps.map((r) => {
|
||||
return Object.assign({}, r, { BDCODE: code });
|
||||
});
|
||||
all2.splice(last + 1, 0, ...copies);
|
||||
}
|
||||
const t = memoText(env, codeOf(src));
|
||||
if (t) {
|
||||
env.memo = env.memo || {};
|
||||
env.memo[code] = t;
|
||||
d.costn_memos = d.costn_memos || {};
|
||||
const m = d.costn_memos[codeOf(src)];
|
||||
d.costn_memos[code] = Object.assign({}, m || { blockNo: 1 }, { code, text: t });
|
||||
}
|
||||
}
|
||||
if (env.state) stateEntry(env.state, row);
|
||||
d.costn_count = rows.length;
|
||||
d.ui_dirty = true;
|
||||
return { ok: true, code, row };
|
||||
}
|
||||
|
||||
// ── 저장 묶음(api_ui_code.py /api/ui/save) ──
|
||||
const EXTRA_COL = [
|
||||
"BQCODE",
|
||||
"EQCODE",
|
||||
"JDCODE",
|
||||
"WQCODE",
|
||||
"EDT_DATE",
|
||||
"NAME2",
|
||||
"SIZE2",
|
||||
"PP",
|
||||
"CONTCODE",
|
||||
];
|
||||
/** 구조 덧붙임 — 원래 행(코드·덧붙임 칸) · 새 행 · 지운 원본 · 다른 표 코드 · 구성표 */
|
||||
function structPayload(d) {
|
||||
const cur = d.boq_table_name || "";
|
||||
const costn_struct = (d.costn_rows || []).map((r) => {
|
||||
const u = { CODE: codeOf(r) };
|
||||
if (r._new)
|
||||
Object.assign(u, { _new: true, _from: r._from, NAME: r.NAME, SIZE: r.SIZE, UNIT: r.UNIT });
|
||||
if (r._new && r._from == null)
|
||||
u._vals = Object.fromEntries(
|
||||
Object.entries(r).filter(
|
||||
([k, v]) =>
|
||||
!k.startsWith("_") && !k.endsWith("_HEX") && k !== "IFORM" && typeof v !== "object",
|
||||
),
|
||||
);
|
||||
else u._recno = r._recno;
|
||||
EXTRA_COL.forEach((k) => k in r && (u[k] = r[k]));
|
||||
return u;
|
||||
});
|
||||
const boq_code_rows = [];
|
||||
boqRowSets(d).forEach(([t, rows]) => {
|
||||
if (t === cur || rows === d.boq_rows) return;
|
||||
rows.forEach(
|
||||
(r) =>
|
||||
r._bd0 &&
|
||||
boq_code_rows.push(
|
||||
Object.assign(
|
||||
{ table: t, _recno: r._recno },
|
||||
Object.fromEntries(BDK.map((k) => [k, r[k]])),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
// 구성표(bdqty_rows)는 기본 저장 묶음이 함께 보냄(stc_save_extra.save_bdqty)
|
||||
return { costn_struct, costn_deleted: d.costn_deleted || [], boq_code_rows };
|
||||
}
|
||||
const needsUiSave = (d) => Boolean(d && d.ui_dirty);
|
||||
|
||||
global.STMATE_UI_CODE = {
|
||||
CODE_RE,
|
||||
KIND_NAME,
|
||||
EXTRA_COL,
|
||||
codeOf,
|
||||
boqRowSets,
|
||||
memoText,
|
||||
memoRefs,
|
||||
usedSet,
|
||||
lastCodes,
|
||||
nextCode,
|
||||
mkCode,
|
||||
codeNum,
|
||||
sameItems,
|
||||
stateRename,
|
||||
stateEntry,
|
||||
remapRefs,
|
||||
checkChange,
|
||||
changeCodes,
|
||||
plusMap,
|
||||
replaceCodes,
|
||||
autoReplaceMap,
|
||||
unusedCodes,
|
||||
unusedBUnit,
|
||||
deleteCodes,
|
||||
addCode,
|
||||
structPayload,
|
||||
needsUiSave,
|
||||
};
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = global.STMATE_UI_CODE;
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* STMATE w 8.0 — 단가 일괄 손질 순수 계산 (ui_code_amt.js) · 화면·DOM 없음(node 시험 가능)
|
||||
* 근거: DFM 원문(C:/경영정보(주)/System) — STmate.Cpm wM_Boxa(적용단가 일괄할증 · 기준단가[AMT_61] · 보호해제시 자동할증
|
||||
* · rg_FLT_M_PP/rg_FLT_B_PP 속성 추려보기 · 명칭/규격 추려보기 · 호환규정코드 검색·중복체크) · ST_Jabpum Edt_Hal_Amt/Edt_Hal_Rate
|
||||
* · ST_Sys wM_Set_Init(분류번호·적용기준일·시설코드·수공분류코드·호환규정코드 초기화 × 비목 8종)
|
||||
* · ST_EdiLWi wM_Edit_XRate(상각비·정비비·관리비 계수 · 계(10^-7) · 손료 적용율) — 시공량은 calc_xrate.js
|
||||
* · DP_MinMsg mMinMsg_(특수문자 14종) · 메뉴 Act_1st_2nd/Act_2nd_1st(당초단가 ⇔ 변경단가)
|
||||
* 일괄변경 세부 필터는 원본 미확정(08번 §3) — 차수·비목·코드 범위·0원 제외를 명시 선택으로 둠
|
||||
*/
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
const txt = (v) => String(v == null ? "" : v).trim();
|
||||
const num = (v) => {
|
||||
const n = parseFloat(txt(v).replace(/,/g, ""));
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
};
|
||||
const codeOf = (r) => txt(r && r.CODE);
|
||||
const OWN = { M: "MAMT", L: "LAMT", S: "SAMT" };
|
||||
|
||||
// 반올림·절사 — 소수 d 자리(음수 = 10^-d 원 단위)
|
||||
function roundTo(v, d, mode) {
|
||||
const f = Math.pow(10, Number(d) || 0);
|
||||
const x = Number((v * f).toFixed(6));
|
||||
return (mode === "round" ? Math.round(x) : Math.trunc(x)) / f;
|
||||
}
|
||||
|
||||
const slotVals = (r, s) => ({
|
||||
M: num(r[`MAMT${s}`]),
|
||||
L: num(r[`LAMT${s}`]),
|
||||
S: num(r[`SAMT${s}`]),
|
||||
});
|
||||
function writeSlot(r, s, v) {
|
||||
["M", "L", "S"].forEach((c) => (r[`${c}AMT${s}`] = String(v[c])));
|
||||
r[`TAMT${s}`] = String(v.M + v.L + v.S);
|
||||
}
|
||||
|
||||
/**
|
||||
* 단가 일괄변경·물가변동(uM_Chg_Amt) — from 차수 단가 × rate% → to 차수 · 비목별 끝전
|
||||
* o = { kinds: "MLSW", from: 1, to: 1, rate: 100, digits: 0, mode: "trunc", skipZero: true, codes: null }
|
||||
* 낙찰률 행(NAGCD>0)은 2차 칸이 100% 단가 자리라 건너뜀 · 결과 = [{ code, from:{T}, to:{T} }]
|
||||
*/
|
||||
function chgAmt(rows, o) {
|
||||
const opt = Object.assign(
|
||||
{ kinds: "MLSW", from: 1, to: 1, rate: 100, digits: 0, mode: "trunc", skipZero: true },
|
||||
o || {},
|
||||
);
|
||||
const only = opt.codes ? new Set(opt.codes) : null;
|
||||
const out = [];
|
||||
(rows || []).forEach((r) => {
|
||||
const c = codeOf(r);
|
||||
if (!c || !opt.kinds.includes(c[0]) || (only && !only.has(c))) return;
|
||||
if (num(r.NAGCD) > 0 && (Number(opt.from) === 2 || Number(opt.to) === 2)) return;
|
||||
const src = slotVals(r, opt.from);
|
||||
const T0 = src.M + src.L + src.S;
|
||||
if (opt.skipZero && !(T0 > 0)) return;
|
||||
const v = {};
|
||||
["M", "L", "S"].forEach(
|
||||
(k) => (v[k] = roundTo((src[k] * num(opt.rate)) / 100, opt.digits, opt.mode)),
|
||||
);
|
||||
const before = slotVals(r, opt.to);
|
||||
if (["M", "L", "S"].every((k) => before[k] === v[k])) return;
|
||||
writeSlot(r, opt.to, v);
|
||||
out.push({ code: c, from: before.M + before.L + before.S, to: v.M + v.L + v.S });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 적용단가 일괄할증(Edt_Hal_Rate · Edt_Hal_Amt) — 기준단가(AMT_61, 없으면 지금 적용단가) 기준
|
||||
* how = "rate"(× (1 + v%)) · "amt"(+ v 원) · 같은 할증을 두 번 해도 결과 같음
|
||||
* 기초단가(M·L·S)는 자기 비목 한 칸 · 견적단가(W)는 비목 비율 그대로 나눔
|
||||
*/
|
||||
function halAll(rows, o) {
|
||||
const opt = Object.assign(
|
||||
{ kinds: "M", how: "rate", value: 0, digits: 0, mode: "trunc" },
|
||||
o || {},
|
||||
);
|
||||
const only = opt.codes ? new Set(opt.codes) : null;
|
||||
const out = [];
|
||||
(rows || []).forEach((r) => {
|
||||
const c = codeOf(r);
|
||||
if (!c || !opt.kinds.includes(c[0]) || (only && !only.has(c))) return;
|
||||
const cur = slotVals(r, 1);
|
||||
const curT = cur.M + cur.L + cur.S;
|
||||
const base = num(r.AMT_61) || curT;
|
||||
if (!(base > 0)) return;
|
||||
const tgt = roundTo(
|
||||
opt.how === "amt" ? base + num(opt.value) : base * (1 + num(opt.value) / 100),
|
||||
opt.digits,
|
||||
opt.mode,
|
||||
);
|
||||
if (!num(r.AMT_61)) r.AMT_61 = String(base);
|
||||
let v;
|
||||
if (OWN[c[0]]) v = Object.assign({ M: 0, L: 0, S: 0 }, { [c[0]]: tgt });
|
||||
else {
|
||||
const k = tgt / base;
|
||||
v = {
|
||||
M: roundTo(cur.M * k, opt.digits, opt.mode),
|
||||
L: roundTo(cur.L * k, opt.digits, opt.mode),
|
||||
};
|
||||
v.S = tgt - v.M - v.L;
|
||||
}
|
||||
if (["M", "L", "S"].every((x) => cur[x] === v[x])) return;
|
||||
writeSlot(r, 1, v);
|
||||
out.push({ code: c, base, from: curT, to: tgt });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// 프로젝트 계산 초기값(wM_Set_Init) — 칸 무리 × 비목 글자
|
||||
const INIT_COLS = { BQ: "BQCODE", ED: "EDT_DATE", EQ: "EQCODE", WQ: "WQCODE", JD: "JDCODE" };
|
||||
const INIT_KINDS = ["L", "M", "S", "W", "B", "X", "D", "A"];
|
||||
function initCols(rows, pick) {
|
||||
const out = {};
|
||||
Object.entries(pick || {}).forEach(([g, kinds]) => {
|
||||
const col = INIT_COLS[g];
|
||||
if (!col) return;
|
||||
(rows || []).forEach((r) => {
|
||||
const c = codeOf(r);
|
||||
if (!c || !String(kinds).includes(c[0]) || !(col in r) || !txt(r[col])) return;
|
||||
r[col] = "";
|
||||
out[col] = (out[col] || 0) + 1;
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// 속성(PP)·명칭/규격 추려보기(rg_FLT_*_PP · mBtn_Flt_*) — 원본 속성 목록(급수 공종)
|
||||
const PP_M = [
|
||||
"10 : 새들분수전",
|
||||
"11 : K.P 자재",
|
||||
"12 : 엘보",
|
||||
"13 : 직관",
|
||||
"14 : 소켓",
|
||||
"15 : S.T.S 자재",
|
||||
];
|
||||
PP_M.push(
|
||||
"16 : 니플",
|
||||
"17 : 죠인트",
|
||||
"18 : 계량기보호통",
|
||||
"19 : 계량기",
|
||||
"20 : 사급자재",
|
||||
"25 : 무선원격검침비",
|
||||
);
|
||||
const PP_B = [
|
||||
"30 : 토공",
|
||||
"31 : 포장공",
|
||||
"32 : 포장깨기/절단",
|
||||
"30~32 : 토공사",
|
||||
"40 : 분기공",
|
||||
"41 : 관접합",
|
||||
];
|
||||
PP_B.push(
|
||||
"42 : 관연결",
|
||||
"43 : 죠인트 접합",
|
||||
"44 : 계량기 설치",
|
||||
"45 : 보호통 설치",
|
||||
"40~45 : 관공사",
|
||||
);
|
||||
PP_B.push(
|
||||
"50 : 부대공",
|
||||
"60 : 폐기물( CON'C )",
|
||||
"61 : 폐기물( ASP )",
|
||||
"62 : 폐기물( BODO )",
|
||||
"65 : 폐기물운반",
|
||||
);
|
||||
function ppMatch(r, opt) {
|
||||
const v = parseInt(txt(r.PP), 10);
|
||||
const m = String(opt || "").match(/^(\d+)(?:~(\d+))?/);
|
||||
if (!m) return true;
|
||||
const a = Number(m[1]),
|
||||
b = m[2] ? Number(m[2]) : a;
|
||||
return v >= a && v <= b;
|
||||
}
|
||||
function nameList(rows, kind, field) {
|
||||
const f = field === "SIZE" ? "SIZE" : "NAME";
|
||||
const seen = new Map();
|
||||
(rows || []).forEach((r) => {
|
||||
const c = codeOf(r);
|
||||
if (c && (!kind || kind.includes(c[0])) && txt(r[f]))
|
||||
seen.set(txt(r[f]), (seen.get(txt(r[f])) || 0) + 1);
|
||||
});
|
||||
return [...seen.entries()].sort((a, b) => a[0].localeCompare(b[0], "ko"));
|
||||
}
|
||||
|
||||
// 불러간 목록(wM_Box_H) — 이 프로젝트 내역서·구성표에 불려 간 코드만
|
||||
function calledCodes(d) {
|
||||
const out = new Set();
|
||||
Object.values((d && d.boq_tables) || {}).forEach((rows) =>
|
||||
(rows || []).forEach((r) => txt(r.BDCODE1) && out.add(txt(r.BDCODE1))),
|
||||
);
|
||||
((d && d.boq_rows) || []).forEach((r) => txt(r.BDCODE1) && out.add(txt(r.BDCODE1)));
|
||||
((d && d.bdqty_rows) || []).forEach((r) => txt(r.CODE) && out.add(txt(r.CODE)));
|
||||
return out;
|
||||
}
|
||||
|
||||
// 호환규정코드 찾기(wM_JDCode wCb_Base) — 기준(명칭 / 명칭·규격 / 분류번호 / 시설코드)이 같은 자료의 코드
|
||||
const JD_BASE = {
|
||||
N: (r) => txt(r.NAME),
|
||||
NS: (r) => `${txt(r.NAME)}|${txt(r.SIZE)}`,
|
||||
BQ: (r) => txt(r.BQCODE),
|
||||
EQ: (r) => txt(r.EQCODE),
|
||||
};
|
||||
function jdCandidates(row, pool, base) {
|
||||
const f = JD_BASE[base] || JD_BASE.NS;
|
||||
const key = f(row);
|
||||
const cnt = new Map();
|
||||
if (!key || key === "|") return [];
|
||||
(pool || []).forEach((p) => {
|
||||
if (!txt(p.JDCODE) || f(p) !== key) return;
|
||||
const x = cnt.get(txt(p.JDCODE)) || {
|
||||
jd: txt(p.JDCODE),
|
||||
n: 0,
|
||||
unit: txt(p.UNIT),
|
||||
src: new Set(),
|
||||
};
|
||||
x.n += 1;
|
||||
if (p.project) x.src.add(p.project);
|
||||
cnt.set(x.jd, x);
|
||||
});
|
||||
return [...cnt.values()]
|
||||
.map((x) => ({ jd: x.jd, n: x.n, unit: x.unit, src: [...x.src] }))
|
||||
.sort((a, b) => b.n - a.n || a.jd.localeCompare(b.jd));
|
||||
}
|
||||
// 호환규정코드 중복체크(Act_Chk_JDCode) — 한 호환코드를 둘 이상 코드가 씀
|
||||
function jdDup(rows) {
|
||||
const g = new Map();
|
||||
(rows || []).forEach(
|
||||
(r) => txt(r.JDCODE) && g.set(txt(r.JDCODE), (g.get(txt(r.JDCODE)) || []).concat(codeOf(r))),
|
||||
);
|
||||
return [...g.entries()].filter(([, a]) => a.length > 1).map(([jd, codes]) => ({ jd, codes }));
|
||||
}
|
||||
|
||||
/**
|
||||
* 시간당 손료계수(wM_Edit_XRate) — 계 = 상각비 + 정비비 + 관리비 계수(×10^-7)
|
||||
* 구성 수량 = 계 × 10^-7 × 1,000(기계가격 천원 단위) × 적용율% · 「계 입력」이면 계만
|
||||
*/
|
||||
const lossQty = (sum, ratePct) =>
|
||||
Number(((num(sum) * 1e-4 * num(ratePct === undefined ? 100 : ratePct)) / 100).toFixed(10));
|
||||
const lossSum = (qty, ratePct) => {
|
||||
const r = num(ratePct === undefined ? 100 : ratePct);
|
||||
return r ? Number(((num(qty) * 1e4 * 100) / r).toFixed(6)) : 0;
|
||||
};
|
||||
// 시공량(기준·일당·적용 수량) 계산은 calc_xrate.js(STMATE_XRATE.compute)
|
||||
|
||||
// 특수문자(mMinMsg_ ListBox1 14종) — KS X 1001 특수문자 줄(A1~AC) 대응 · 분수/첨자·특수외국어는 A8·A9 일부
|
||||
const SPECIAL = [
|
||||
["기호1", [0xa1]],
|
||||
["기호2", [0xa2]],
|
||||
["영어/숫자", [0xa3]],
|
||||
["한글자모", [0xa4]],
|
||||
["그리스어", [0xa5]],
|
||||
["선문자", [0xa6]],
|
||||
["단위기호", [0xa7]],
|
||||
["원문자", [0xa8], [0xb1, 0xf8]],
|
||||
["괄호문자", [0xa9], [0xb1, 0xf6]],
|
||||
["히라가다", [0xaa]],
|
||||
["가다가나", [0xab]],
|
||||
["러시아어", [0xac]],
|
||||
["특수외국어", [0xa8, 0xa9], [0xa1, 0xb0]],
|
||||
["분수/첨자", [0xa8, 0xa9], [0xf6, 0xfe]],
|
||||
];
|
||||
function specialChars(i, decode) {
|
||||
const [, leads, range] = SPECIAL[i] || SPECIAL[0];
|
||||
const [a, b] = range || [0xa1, 0xfe];
|
||||
const out = [];
|
||||
leads.forEach((lead) => {
|
||||
for (let t = a; t <= b; t++) {
|
||||
const ch = decode(new Uint8Array([lead, t]));
|
||||
if (ch && ch !== "\ufffd" && !/^\s$/.test(ch)) out.push(ch);
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// 사용자문자(wM_Edit_USER) — 검색명칭 → 입력내용 · 적용 표시된 것만
|
||||
function userTextFind(list, kw) {
|
||||
const k = txt(kw).toLowerCase();
|
||||
return (list || []).filter(
|
||||
(x) => x.on !== false && (!k || txt(x.key).toLowerCase().includes(k)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 실적단가 → 일식단가 변환(wM_Conv_A2W) — 줄마다 비목율(L·M·E %, calc_a2w.js bulkApply 결과)로 단가를 나눔
|
||||
* src = [{ CODE, NAME, SIZE, UNIT, AAMT1, L, M, E }] · start = 첫 W 코드 · 노무·재료 절사, 경비 = 나머지
|
||||
*/
|
||||
function a2w(src, o) {
|
||||
const opt = Object.assign({ digits: 0, start: "W00001", taken: null }, o || {});
|
||||
let n = parseInt(String(opt.start).slice(1), 10) || 1;
|
||||
return (src || []).map((r) => {
|
||||
const p = num(r.AAMT1 || r.TAMT1);
|
||||
const L = roundTo((p * num(r.L)) / 100, opt.digits, "trunc");
|
||||
const M = roundTo((p * num(r.M)) / 100, opt.digits, "trunc");
|
||||
while (opt.taken && opt.taken.has(`W${String(n).padStart(5, "0")}`)) n += 1;
|
||||
const code = `W${String(n++).padStart(5, "0")}`;
|
||||
return {
|
||||
from: codeOf(r),
|
||||
CODE: code,
|
||||
NAME: r.NAME,
|
||||
SIZE: r.SIZE,
|
||||
UNIT: r.UNIT,
|
||||
M,
|
||||
L,
|
||||
S: p - L - M,
|
||||
T: p,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
global.STMATE_UI_CODE_AMT = {
|
||||
roundTo,
|
||||
chgAmt,
|
||||
halAll,
|
||||
INIT_COLS,
|
||||
INIT_KINDS,
|
||||
initCols,
|
||||
PP_M,
|
||||
PP_B,
|
||||
ppMatch,
|
||||
nameList,
|
||||
calledCodes,
|
||||
jdCandidates,
|
||||
jdDup,
|
||||
lossQty,
|
||||
lossSum,
|
||||
SPECIAL,
|
||||
specialChars,
|
||||
userTextFind,
|
||||
a2w,
|
||||
};
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = global.STMATE_UI_CODE_AMT;
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
@@ -3,7 +3,7 @@
|
||||
* 근거: ui_form_catalog.txt wM_JabPum cBoxCode(10종)·cBox_IFORM · wM_Boxa RG_MComp(변동없음/1~5 단가/최소단가)
|
||||
* · 22_UI_폼_분석 §3.1 wM_Edt_Hal(적용단가 = 기준단가 × % · 소수 자리 미만 절사/반올림 · 적용단가 0원 제외)
|
||||
* · STC_필드사전_핵심(AMT_11~51 후보 5칸 · PG_1 = 채택 출처 · PG_11~51 페이지) · Start8x.Ini JMOK1~5·JMK10~50·JEMNM
|
||||
* 그림코드는 실자료 표본에 없음 — 앞 글자 미확인이라 걸러지는 행 없음
|
||||
* 그림코드 = I 코드(DFM uM_ChgCode·uM_DelCode·wM_Boxa 의 Tab_I 「그림코드(Ctrl+I)」 · Act_Sel_I) — 실자료 표본에는 없음
|
||||
*/
|
||||
(function (global) {
|
||||
"use strict";
|
||||
@@ -28,7 +28,7 @@
|
||||
["D", "단가산출", (p) => p === "D"],
|
||||
["W", "견적단가", (p) => p === "W"],
|
||||
["BD", "대가,산근", (p) => p === "B" || p === "D"],
|
||||
["G", "그림코드", () => false],
|
||||
["G", "그림코드", (p) => p === "I"],
|
||||
];
|
||||
const OWN = { M: "MAMT", L: "LAMT", S: "SAMT" };
|
||||
const isBase = (code) => /^[MLS]/.test(code);
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* STMATE w 8.0 — 코드병합·데이터병합 순수 계산 (ui_merge.js) · 복사처(지금 프로젝트) ← 복사원(다른 프로젝트)
|
||||
* 근거: DFM 원문 — ST_Merge_Code.CPM wM_Stmate_Merge_Code(rg_Set_Sys 4택: 선택코드만 / 선택코드+하위기초단가코드 /
|
||||
* 모든코드(하위 산근,대가포함) / 코드병합 취소 · 「복사처에 등록되지 않은코드 : 코드번호 생성(병합)」)
|
||||
* · ST_U_Merge.CPM uM_Merge(RdoL~RdoW: 복사처적용(동일항목 코드치환) / 복사원적용(동일항목 내용복사) /
|
||||
* 신규등록(단가다른코드 신규생성) · Rdo_BD·Rdo_X 일식단가(W코드)로 생성 · Rdo_Sel_Con 동일항목 연결 3택
|
||||
* · 「동일항목 이란? - 명칭+규격+단위 가 복사처와 같은것으로 코드번호는 무관함」 · 메뉴 Act_Merge_C 코드번호로 인식)
|
||||
*/
|
||||
(function (global) {
|
||||
"use strict";
|
||||
const UC =
|
||||
global.STMATE_UI_CODE || (typeof require !== "undefined" ? require("./ui_code.js") : null);
|
||||
const txt = (v) => String(v == null ? "" : v).trim();
|
||||
const num = (v) => Number(txt(v).replace(/,/g, "")) || 0;
|
||||
const codeOf = (r) => txt(r && r.CODE);
|
||||
const REF_RE = /&([A-Z]\d{5})([LMS]?(?::[LMS])?)&/g;
|
||||
|
||||
// 복사원 자료 — 코드 → 행 · 구성 · 식
|
||||
function srcIndex(src) {
|
||||
const by = new Map((src.costn_rows || []).map((r) => [codeOf(r), r]));
|
||||
const comp = new Map();
|
||||
(src.bdqty_rows || []).forEach((r) => {
|
||||
const p = txt(r.BDCODE);
|
||||
comp.set(p, (comp.get(p) || []).concat(r));
|
||||
});
|
||||
const memo = (c) => ((src.costn_memos || {})[c] || {}).text || "";
|
||||
const kids = (c) => {
|
||||
const a = (comp.get(c) || []).map((r) => txt(r.CODE));
|
||||
return a.concat([...memo(c).matchAll(REF_RE)].map((m) => m[1])).filter((k) => by.has(k));
|
||||
};
|
||||
return { by, comp, memo, kids };
|
||||
}
|
||||
|
||||
/** 병합할 코드 — mode 0 선택코드만 · 1 +하위 기초단가(M·L·S) · 2 모든 하위(산근·대가·중기 포함) */
|
||||
function closure(src, codes, mode) {
|
||||
const ix = srcIndex(src);
|
||||
const out = new Set();
|
||||
const walk = (c, deep) => {
|
||||
if (!ix.by.has(c) || (out.has(c) && deep)) return;
|
||||
out.add(c);
|
||||
if (mode === 0) return;
|
||||
ix.kids(c).forEach((k) => {
|
||||
if (/^[MLS]/.test(k)) out.add(k);
|
||||
else if (mode === 2) walk(k, true);
|
||||
});
|
||||
};
|
||||
(codes || []).forEach((c) => walk(c, true));
|
||||
return [...out];
|
||||
}
|
||||
|
||||
// 동일항목 열쇠 — conn 0 「1차 호환, 2차 시설, 3차 명칭..단위」 · 1 「1차 호환, 2차 명칭+규격+단위」 · 2 명칭+규격+단위 · "code" 코드번호
|
||||
const nsu = (r) => [txt(r.NAME), txt(r.SIZE), txt(r.UNIT)].join("\u0001");
|
||||
function findSame(r, dest, conn) {
|
||||
const k = codeOf(r)[0];
|
||||
const pool = dest.filter((x) => codeOf(x)[0] === k);
|
||||
if (conn === "code") return pool.find((x) => codeOf(x) === codeOf(r)) || null;
|
||||
const tries = [];
|
||||
if (conn === 0 || conn === 1)
|
||||
tries.push((x) => txt(r.JDCODE) && txt(x.JDCODE) === txt(r.JDCODE));
|
||||
if (conn === 0) tries.push((x) => txt(r.EQCODE) && txt(x.EQCODE) === txt(r.EQCODE));
|
||||
// 명칭+규격+단위 — 단가까지 같은 항목 먼저
|
||||
tries.push((x) => nsu(x) === nsu(r) && num(x.TAMT1) === num(r.TAMT1));
|
||||
tries.push((x) => nsu(x) === nsu(r));
|
||||
for (const f of tries) {
|
||||
const hit = pool.find(f);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const POLICY = ["복사처적용", "복사원적용", "신규등록"];
|
||||
const PRICE = ["TAMT1", "LAMT1", "MAMT1", "SAMT1", "TAMT2", "LAMT2", "MAMT2", "SAMT2"];
|
||||
const COPY_KEEP = [
|
||||
"NAME",
|
||||
"SIZE",
|
||||
"UNIT",
|
||||
...PRICE,
|
||||
"AMT_11",
|
||||
"AMT_21",
|
||||
"AMT_31",
|
||||
"AMT_41",
|
||||
"AMT_51",
|
||||
];
|
||||
COPY_KEEP.push(
|
||||
"AMT_61",
|
||||
"PG_1",
|
||||
"PG_11",
|
||||
"PG_21",
|
||||
"PG_31",
|
||||
"PG_41",
|
||||
"PG_51",
|
||||
"JDCODE",
|
||||
"EDT_DATE",
|
||||
"BQCODE",
|
||||
"EQCODE",
|
||||
);
|
||||
|
||||
/**
|
||||
* 병합 계획 — 코드마다 { src, act: "map"|"copy"|"new"|"w", dest } (자식 먼저)
|
||||
* o = { codes, mode(0~2), conn(0~2|"code"), policy: {L,M,S,B,D,W: 0~2}, bdW, xW, start: {kind: 코드} }
|
||||
*/
|
||||
function plan(env, src, o) {
|
||||
const opt = Object.assign(
|
||||
{ mode: 2, conn: 2, policy: {}, bdW: false, xW: false, start: {} },
|
||||
o || {},
|
||||
);
|
||||
const d = env.d;
|
||||
const ix = srcIndex(src);
|
||||
const want = new Set(closure(src, opt.codes, opt.mode));
|
||||
const order = [];
|
||||
const seen = new Set();
|
||||
const visit = (c) => {
|
||||
if (seen.has(c) || !want.has(c)) return;
|
||||
seen.add(c);
|
||||
ix.kids(c).forEach(visit);
|
||||
order.push(c);
|
||||
};
|
||||
[...want].forEach(visit);
|
||||
const taken = new Set((d.costn_rows || []).map(codeOf));
|
||||
const counters = {};
|
||||
const newCode = (k) => {
|
||||
const start = txt((opt.start || {})[k]);
|
||||
if (counters[k] == null)
|
||||
counters[k] =
|
||||
start && UC.CODE_RE.test(start)
|
||||
? UC.codeNum(start)
|
||||
: UC.codeNum(UC.nextCode(d.costn_rows, k, taken) || `${k}00001`);
|
||||
while (taken.has(UC.mkCode(k, counters[k]))) counters[k] += 1;
|
||||
const c = UC.mkCode(k, counters[k]);
|
||||
taken.add(c);
|
||||
return c;
|
||||
};
|
||||
return order.map((c) => {
|
||||
const r = ix.by.get(c);
|
||||
let k = c[0];
|
||||
const toW = (opt.bdW && (k === "B" || k === "D")) || (opt.xW && k === "X");
|
||||
if (toW) k = "W";
|
||||
const pol = Number((opt.policy || {})[toW ? "W" : c[0]] || 0);
|
||||
const same = toW ? null : findSame(r, d.costn_rows || [], opt.conn);
|
||||
if (same) {
|
||||
if (pol === 2 && num(same.TAMT1) !== num(r.TAMT1))
|
||||
return { src: c, act: "new", dest: newCode(k), same: codeOf(same) };
|
||||
return { src: c, act: pol === 1 ? "copy" : "map", dest: codeOf(same) };
|
||||
}
|
||||
return { src: c, act: toW ? "w" : "new", dest: newCode(k) };
|
||||
});
|
||||
}
|
||||
|
||||
/** 병합 실행 — 계획대로 새 코드 등록·내용 복사·코드 치환(구성표·식의 복사원 코드 → 복사처 코드) */
|
||||
function apply(env, src, steps) {
|
||||
const d = env.d;
|
||||
const ix = srcIndex(src);
|
||||
const map = Object.fromEntries(steps.map((s) => [s.src, s.dest]));
|
||||
const unresolved = new Set();
|
||||
// 계획 밖 하위 코드 — 복사처의 같은 명칭·규격·단위 항목, 없으면 그대로 두고 알림
|
||||
const resolve = (c) => {
|
||||
if (map[c] || !ix.by.has(c)) return map[c] || c;
|
||||
const same = findSame(ix.by.get(c), d.costn_rows || [], 2);
|
||||
if (same) return (map[c] = codeOf(same));
|
||||
unresolved.add(c);
|
||||
return c;
|
||||
};
|
||||
env.memo = env.memo || {};
|
||||
d.costn_memos = d.costn_memos || {};
|
||||
d.bdqty_rows = d.bdqty_rows || [];
|
||||
const remapText = (t) =>
|
||||
String(t || "").replace(REF_RE, (all, c, tail) => `&${resolve(c)}${tail}&`);
|
||||
// 구성행 칸 — 복사처 구성표가 읽는 칸만(프로젝트마다 복호되는 칸이 다름)
|
||||
const keys = new Set(Object.keys(d.bdqty_rows[0] || { BDCODE: 1, CODE: 1, QTY1: 1 }));
|
||||
const putBody = (srcCode, dest) => {
|
||||
d.bdqty_rows = d.bdqty_rows.filter((q) => txt(q.BDCODE) !== dest);
|
||||
(ix.comp.get(srcCode) || []).forEach((q) => {
|
||||
const x = Object.fromEntries(
|
||||
Object.entries(q).filter(([k]) => keys.has(k) && !k.startsWith("_")),
|
||||
);
|
||||
Object.assign(x, { BDCODE: dest, CODE: resolve(txt(q.CODE)) });
|
||||
d.bdqty_rows.push(x);
|
||||
});
|
||||
const t = ix.memo(srcCode);
|
||||
if (t) {
|
||||
const t2 = remapText(t);
|
||||
env.memo[dest] = t2;
|
||||
d.costn_memos[dest] = Object.assign({}, d.costn_memos[dest] || { blockNo: 1 }, {
|
||||
code: dest,
|
||||
text: t2,
|
||||
});
|
||||
}
|
||||
};
|
||||
const n = { new: 0, copy: 0, map: 0, w: 0 };
|
||||
steps.forEach((s) => {
|
||||
const r = ix.by.get(s.src);
|
||||
n[s.act] += 1;
|
||||
if (s.act === "map") return;
|
||||
if (s.act === "copy") {
|
||||
const t = d.costn_rows.find((x) => codeOf(x) === s.dest);
|
||||
COPY_KEEP.forEach((k) => k in r && (t[k] = r[k]));
|
||||
if (!/^[MLSW]/.test(s.dest)) putBody(s.src, s.dest);
|
||||
return;
|
||||
}
|
||||
const row = Object.fromEntries(
|
||||
Object.entries(r).filter(
|
||||
([k, v]) =>
|
||||
!k.startsWith("_") && !k.endsWith("_HEX") && k !== "IFORM" && typeof v !== "object",
|
||||
),
|
||||
);
|
||||
Object.assign(row, { CODE: s.dest, _new: true, _deleted: false });
|
||||
d.costn_rows.push(row);
|
||||
if (s.act !== "w") putBody(s.src, s.dest);
|
||||
if (env.state) UC.stateEntry(env.state, row);
|
||||
});
|
||||
d.costn_count = d.costn_rows.length;
|
||||
d.ui_dirty = true;
|
||||
return { ok: true, map, count: n, unresolved: [...unresolved] };
|
||||
}
|
||||
|
||||
/** 내역서 행 가져오기 — 복사원 내역 단가코드 행을 복사처 코드로 바꿔 새 행 값으로 */
|
||||
function boqRowsFrom(srcRows, map) {
|
||||
return (srcRows || []).map((r) => {
|
||||
const c = txt(r.BDCODE1);
|
||||
return {
|
||||
BDCODE1: map[c] || c,
|
||||
NAME1: r.NAME1 || "",
|
||||
SIZE1: r.SIZE1 || "",
|
||||
UNIT1: r.UNIT1 || "",
|
||||
QTY1: String(num(r.QTY1)),
|
||||
ITNUM: r.ITNUM || "",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 자료파일 업데이트(wM_Mrg_BSC) — 매칭 1~5차(cBx_Sel1~5) 차례로 복사원 같은 항목을 찾아 명칭·단가·조달코드 갱신
|
||||
* keys: "JD"(호환규정코드) · "EQ"(시설코드) · "NSU"(명칭/규격/단위) · "JNSU"(호환+명+규+단) · "NU"(명칭/단위) · ""(설정안함)
|
||||
* o = { keys, name: 0|1, amt: 0|1, jd: 0 안함 | 1 복사원 | 2 복사처 우선(비었을 때만) | 3 복사원 우선(복사원에 있을 때) , kinds }
|
||||
*/
|
||||
const MK = {
|
||||
JD: (r) => txt(r.JDCODE),
|
||||
EQ: (r) => txt(r.EQCODE),
|
||||
NSU: (r) => (txt(r.NAME) ? nsu(r) : ""),
|
||||
JNSU: (r) => (txt(r.JDCODE) && txt(r.NAME) ? `${txt(r.JDCODE)}${nsu(r)}` : ""),
|
||||
NU: (r) => (txt(r.NAME) ? `${txt(r.NAME)}${txt(r.UNIT)}` : ""),
|
||||
};
|
||||
const AMT = [
|
||||
"TAMT1",
|
||||
"LAMT1",
|
||||
"MAMT1",
|
||||
"SAMT1",
|
||||
"AMT_11",
|
||||
"AMT_21",
|
||||
"AMT_31",
|
||||
"AMT_41",
|
||||
"AMT_51",
|
||||
"AMT_61",
|
||||
"PG_1",
|
||||
];
|
||||
function mrgBsc(dest, src, o) {
|
||||
const opt = Object.assign(
|
||||
{ keys: ["JD", "NSU"], name: 0, amt: 1, jd: 0, kinds: "LMSW" },
|
||||
o || {},
|
||||
);
|
||||
const keys = opt.keys.filter((k) => MK[k]);
|
||||
const idx = keys.map((k) => {
|
||||
const m = new Map();
|
||||
(src || []).forEach((r) => {
|
||||
const v = MK[k](r);
|
||||
if (v && opt.kinds.includes(codeOf(r)[0]) && !m.has(`${codeOf(r)[0]}|${v}`))
|
||||
m.set(`${codeOf(r)[0]}|${v}`, r);
|
||||
});
|
||||
return m;
|
||||
});
|
||||
const out = [];
|
||||
(dest || []).forEach((r) => {
|
||||
const c = codeOf(r);
|
||||
if (!c || !opt.kinds.includes(c[0])) return;
|
||||
let hit = null,
|
||||
by = "";
|
||||
keys.some((k, i) => {
|
||||
const v = MK[k](r);
|
||||
hit = v ? idx[i].get(`${c[0]}|${v}`) : null;
|
||||
by = k;
|
||||
return Boolean(hit);
|
||||
});
|
||||
if (!hit) return;
|
||||
const ch = [];
|
||||
if (opt.name)
|
||||
["NAME", "SIZE", "UNIT"].forEach(
|
||||
(k) => txt(r[k]) !== txt(hit[k]) && (ch.push(k), (r[k] = hit[k])),
|
||||
);
|
||||
if (opt.amt)
|
||||
AMT.forEach((k) => k in hit && num(r[k]) !== num(hit[k]) && (ch.push(k), (r[k] = hit[k])));
|
||||
const jd = txt(hit.JDCODE);
|
||||
const takeJd = opt.jd === 1 || (opt.jd === 2 && !txt(r.JDCODE) && jd) || (opt.jd === 3 && jd);
|
||||
if (takeJd && txt(r.JDCODE) !== jd) (ch.push("JDCODE"), (r.JDCODE = jd));
|
||||
if (ch.length) out.push({ code: c, from: codeOf(hit), by, fields: ch });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
global.STMATE_UI_MERGE = {
|
||||
POLICY,
|
||||
srcIndex,
|
||||
closure,
|
||||
findSame,
|
||||
plan,
|
||||
apply,
|
||||
boqRowsFrom,
|
||||
mrgBsc,
|
||||
};
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = global.STMATE_UI_MERGE;
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
Reference in New Issue
Block a user