/* -*- coding: utf-8 -*- */ /** * 기성 계산 (progress_calc.js) — 회차별 전회·금회·누계 금액과 비율 · 공종별 기성수량·잔량 · 기성 제잡비 · 부가세 4택 * 근거: 지식DB STmate/REA 분석/33_기성준공단계_작업자료_20261114(원본 정적 분석 — 주소는 아래 각 규칙 옆) * · STmate/old/20_분석 27 §3(화면 구성) * 칸 번호(원본 BOQ QTYn·T_AMTn · PRJLST TCOSTn · PRJLST.SMT [n_KANJUB]) — 1 계약(도급) · 2 누계 · 3 전회 · 4 금회 * (ST_KanJub 0x4cae9b 열→칸 표 '1342' · 0x4cb03c 값 배열 순서 · ST_P_ConEst 0x47fadf~0x47fd4c) * 금액: 금회 = 금회수량으로 · 누계 = 누계수량으로 각각 다시 셈(ST_Boq 0x4fc674 · 0x4fce6e→T_AMT2 0x4fcf24) * · 전회 = 앞 회차 누계 이관(STmate 0x55d0b3~0x55d148 「전회<-누계, 금회=0」) — 전회+금회 ≠ 누계(끝전) 있음 * 단가: 기성(PCLASS 6) 편집 때 수량 칸 = 4 · 단가 칸 = 1(계약) — ST_Boq 0x4e49b5([0x541894]=4 · [0x541898]=1) * → 금회·누계 모두 계약 단가 × 그 칸 수량 (단가 칸 인자 뜻은 호출 대응으로 읽음 — 신뢰도 중) * 회차 등록(nextRound): QTY3←QTY2 · QTY4=0 · T_AMT3←T_AMT2 · TCOST3←TCOST2 · TCOST4=0 * · [3_KANJUB]←[2_KANJUB](키 앞 글자 바꿈) · [4_KANJUB] 지움 · K_ORD+1 (STmate 0x55b328~0x55b3cb · 0x455e68) * 시험: resources/tester/test_stmate_stage_progress.py · test_stmate_stage_progress_rea.py */ const StmateProgress = (() => { const Calc = (typeof window !== "undefined" && window.STMATE_CALC) || require("./calc.js"); const Change = (typeof window !== "undefined" && window.STMATE_CHANGE) || require("./change_calc.js"); const { roundDown } = Calc; const n0 = (v) => Number(v) || 0; const r4 = (v) => Math.round(n0(v) * 1e4) / 1e4; const KEYS = ["mAmt", "lAmt", "sAmt", "tAmt"]; // 부가세 4택(cmBx_Buga · DFM TWM_KANJUB_K) — 0 직접입력 · 1 공급가액 10% · 2 재료비 10% · 3 재료비+산출경비 10% const VAT_MODES = ["직접입력", "공급가액의 10%", "재료비의 10%", "재료비+산출경비의 10%"]; // 집계수준(rd_Filter 0x4cadbc) — 거르기 (BDCODE1="T") .and. (BDCODE1>"T"+순번) → 머리 단 ≥ 순번+1 · 처음 값 순번 2 const LEVELS = [ [0, "공종 전부"], [1, "소계(+=) 이상"], [2, "계(+==) 이상"], [3, "합계(+===) 이상"], [4, "총계(+====) 이상"], [5, "총합계(+=====) 이상"], ]; const DEFAULT_LEVEL = 3; // 앱 제잡비 코드 → 원본 저장키 C_ 코드(0x4cb564 행 목록 · 0x4caa48 「칸_C_코드」) const C_CODE = { KANNO: "C_KANNO", SANBO: "C_SANBO", KOBO: "C_KOBO", KUNBO: "C_KUNBO", SLVBO: "C_SLVBO", YUNBO: "C_YUNBO", TBUGUM: "C_TBUGUM", ANJUN: "C_ANJUN", KITA: "C_T_KITA", HWAN: "C_HWAN", EQIP: "C_EQIPSU", ILBAN: "C_ILBAN", IYUN: "C_IYUN", }; // 계약 기준 — 계약 내역(contract_calc.convert)과 계약 원가계산서에서 공종·계약잡비율을 뽑음 function contractBase(conv, kj) { const stage = conv.stage; const leaves = conv.nodes .filter((nd) => nd.kind === "leaf" && !nd.missing) .map((nd) => { const s = nd.st[stage]; const k = nd.pct ? 100 : 1; // % 단위 공종 = 밑수 ÷ 100 을 단가로 return { id: nd.id, recno: nd.recno, code: nd.newCode || nd.code, name: nd.name, spec: nd.spec, unit: nd.unit, gov: nd.gov, pct: !!nd.pct, qty: n0(s.qty), u: { m: n0(s.mUnit) / k, l: n0(s.lUnit) / k, s: n0(s.sUnit) / k }, amt: { mAmt: s.mAmt, lAmt: s.lAmt, sAmt: s.sAmt, tAmt: s.tAmt }, }; }); const direct = n0(kj.dT); // 요율 행(원본 종류 1) — 간접노무비~기타경비 · 일반관리비 · 이윤 const lines = kj.items .filter((it) => n0(it.amount) !== 0) .map((it) => ({ code: it.code, label: it.label, amount: n0(it.amount) })) .concat([ { code: "ILBAN", label: "일반관리비", amount: n0(kj.ilbanAmt) }, { code: "IYUN", label: "이윤", amount: n0(kj.iyunFinal) }, ]); // 계약잡비율(%) = 계약 항목 ÷ 계약 직접공사비 × 100 — 버림 없이 그대로(0x4cb2cc~0x4cb300) const ratePct = Object.fromEntries( lines.map((l) => [l.code, direct ? (l.amount / direct) * 100 : 0]), ); const rates = Object.fromEntries(lines.map((l) => [l.code, ratePct[l.code] / 100])); return { conv, kj, leaves, lines, rates, ratePct, direct, nodes: conv.nodes, stage }; } function amountsAt(lf, qty) { const m = roundDown(lf.u.m * qty, 0), l = roundDown(lf.u.l * qty, 0), s = roundDown(lf.u.s * qty, 0); return { mAmt: m, lAmt: l, sAmt: s, tAmt: m + l + s }; } const add = (a, b) => Object.fromEntries(KEYS.map((k) => [k, n0(a[k]) + n0(b[k])])); const zero = () => Object.fromEntries(KEYS.map((k) => [k, 0])); // 비율 = 금액 ÷ 계약 × 100 — 화면 서식 '0.00%'(0x4cdc7a · 0x4cea20) 두 자리 const pct = (a, b) => (b ? Math.round((a / b) * 1e4) / 1e2 : 0); // 금회수량 상한(ST_Boq 0x4f4bbd~0x4f4c9f) — 계약×금회 > 0 이고 |전회+금회| > |계약| 이면 금회 = 계약 − 전회 // 켜짐이면 상한 없음 = 보기/설정 「누계수량 제한해제 (계약수량 〈 누계수량)」 Act_Enabl_OVR6(TwBoq +0x1068) // → opt.allowOver · 처음 값 꺼짐(DFM Checked 없음) function capCur(ctrQ, prevQ, q, allowOver) { if (allowOver || !(ctrQ * q > 0)) return q; return Math.abs(r4(prevQ + q)) > Math.abs(ctrQ) ? r4(ctrQ - prevQ) : q; } // 회차 하나의 기성 제잡비 계산서(rGrd_Jab) — ST_KanJub 0x4cdec0(계약제잡비율 적용) · 0x4ce6c4(총공사비 계산) // cur: 금회 직접비 {mAmt,lAmt,sAmt,tAmt} · prev: 앞 회차 누계 표(없으면 0) · accDirect: 누계 직접비 function jabFor(base, cur, opt, prev, accDirect) { const dT = cur.mAmt + cur.lAmt + cur.sAmt; const prevOf = (code) => prev ? n0((prev.items.find((x) => x.code === code) || {}).amount) : 0; // 누계직접 = 도급직접 이면 요율 행 금회 = 도급 − 전회(0x4cdf53 비교 · 0x4cdfae~0x4cdff3) const final = n0(accDirect) === n0(base.direct) && base.direct !== 0; const items = base.lines.map((l) => { const p = prevOf(l.code); let amount = final ? l.amount - p : roundDown((base.ratePct[l.code] * dT) / 100, 0); // MROUNDUD(율 × 금회직접 / 100, 0, 0) 0x4ce025 // 상한 — 금회 ≥ 도급 + 전회 이면 금회 = 도급 − 전회(0x4ce045~0x4ce0db · 원본 비교 그대로) if (amount >= l.amount + p) amount = l.amount - p; return { code: l.code, label: l.label, rate: base.rates[l.code], amount }; }); const iy = items.find((it) => it.code === "IYUN"); const sj = Math.abs(n0(opt.supplyJeol)); const tj = sj ? 0 : Math.abs(n0(opt.totalJeol)); // 두 절사는 하나만(cmBx_ValueChange 0x4cef28~0x4cef84) // 이윤 직접입력 = 총공사비 절사 「이윤금액 직접입력」(값 0)일 때 이윤 금회 칸 손입력 if (iy && !tj && opt.iyunManual != null && opt.iyunManual !== "") iy.amount = n0(opt.iyunManual); const mode = opt.vatMode == null ? 1 : Number(opt.vatMode); // 소계 이후 다시 셈(0x4ce2c8) — 공급가액 → 부가세(4택) → 도급 const settle = () => { const supply = dT + items.reduce((a, it) => a + it.amount, 0); const vat = mode === 0 ? n0(opt.vatManual) : mode === 2 ? roundDown(cur.mAmt * 0.1, 0) // 0.1 × 금회 재료비(MCOST4) 0x4ce42e : mode === 3 ? roundDown((cur.mAmt + cur.sAmt) * 0.1, 0) // 0.1 × (MCOST4 + SCOST4) 0x4ce466 : roundDown(supply * 0.1, 0); // 0.1 × 부가세 행까지 누적(=공급가액) 0x4ce3f6 return { supply, vat, total: supply + vat }; }; let s = settle(); let cut = 0; const orig = s.total; if (iy && (sj || tj)) { // 끝전 조정량 0x4ce55c — 공급가액 절사: 공급 − 버림(공급) · 총공사비 절사: 총 − 버림(총) (부가세 1택이면 ÷1.1 버림) if (sj) cut = s.supply - roundDown(s.supply, -sj); else { cut = s.total - roundDown(s.total, -tj); if (mode === 1) cut = roundDown(cut / 1.1, 0); } iy.amount -= cut; // 이윤 금회 − 조정량(0x4ce38b~0x4ce3c1) s = settle(); if (tj && mode === 1) { // 총액 − 목표가 1~10원 남으면 이윤 1원씩 더 깎음(0x4ce8c0~0x4ce90d) const target = roundDown(orig, -tj); for (let g = 0; g < 1000; g++) { const d = Math.round(s.total - target); if (d < 1 || d > 10) break; iy.amount -= 1; cut += 1; s = settle(); } } } return { direct: dT, items, supply: s.supply, vat: s.vat, total: s.total, totalCut: cut, final, }; } const zeroJab = (cur) => ({ direct: 0, items: cur.items.map((it) => Object.assign({}, it, { amount: 0 })), supply: 0, vat: 0, total: 0, }); // 행마다 누계 = 전회 + 금회(0x4ce4a4~0x4ce4e6) · 부분합 행도 같은 누적 const sumJab = (a, b) => ({ direct: a.direct + b.direct, items: b.items.map((it, i) => Object.assign({}, it, { amount: a.items[i].amount + it.amount })), supply: a.supply + b.supply, vat: a.vat + b.vat, total: a.total + b.total, }); // 회차 실행 — rounds[k] = { qtyById: {id: 금회수량}, pct: 일괄 기성율(%), vatMode, vatManual, ... } // seed(기성 프로젝트 불러오기) = { kOrd, qtyById: 전회 수량, amtById: 전회 금액, jab: 전회 제잡비 {코드: 금액} } function run(base, rounds, seed) { const sd = seed || {}; const accQty = Object.fromEntries( base.leaves.map((lf) => [lf.id, n0(sd.qtyById && sd.qtyById[lf.id])]), ); const accAmt = Object.fromEntries( base.leaves.map((lf) => { const a = sd.amtById && sd.amtById[lf.id]; return [lf.id, a ? Object.assign(zero(), a) : amountsAt(lf, accQty[lf.id])]; }), ); let prevJab = null; if (sd.jab) { const items = base.lines.map((l) => ({ code: l.code, label: l.label, rate: base.rates[l.code], amount: n0(sd.jab[l.code]), })); const direct = base.leaves .filter((lf) => !lf.gov) .reduce((a, lf) => a + accAmt[lf.id].tAmt, 0); prevJab = { direct, items, supply: n0(sd.jab.SUPPLY), vat: n0(sd.jab.BUGA), total: 0 }; prevJab.total = prevJab.supply + prevJab.vat; } const k0 = Math.max(1, n0(sd.kOrd) || 1); const out = []; rounds.forEach((rd, k) => { const rows = base.leaves.map((lf) => { const prevQ = accQty[lf.id]; const own = rd.qtyById && rd.qtyById[lf.id] != null; // 첫 기성 등록 때 % 단위 공종은 금회 = 계약수량(STmate 0x55ccb1~0x55cd75 · P/R 코드 행도 같음) let q = own ? n0(rd.qtyById[lf.id]) : k === 0 && !seed && lf.pct && rd.pctFirstFull !== false ? lf.qty : r4((lf.qty * n0(rd.pct)) / 100); q = capCur(lf.qty, prevQ, q, rd.allowOver); const nowQ = r4(prevQ + q); // 누계 = 전회 + 금회(ST_Boq 0x4f4ca4~0x4f4ceb) const prev = accAmt[lf.id], cur = amountsAt(lf, q), acc = amountsAt(lf, nowQ); accQty[lf.id] = nowQ; accAmt[lf.id] = acc; return { id: lf.id, recno: lf.recno, code: lf.code, name: lf.name, spec: lf.spec, unit: lf.unit, gov: lf.gov, ctrQty: lf.qty, prevQty: prevQ, curQty: q, accQty: nowQ, remainQty: r4(lf.qty - nowQ), qtyRate: pct(nowQ, lf.qty), ctr: lf.amt, prev, cur, acc, prevRate: pct(prev.tAmt, lf.amt.tAmt), curRate: pct(cur.tAmt, lf.amt.tAmt), accRate: pct(acc.tAmt, lf.amt.tAmt), }; }); const sumOf = (key) => rows.filter((r) => !r.gov).reduce((a, r) => add(a, r[key]), zero()); const totals = { ctr: sumOf("ctr"), prev: sumOf("prev"), cur: sumOf("cur"), acc: sumOf("acc"), }; const jabCur = jabFor(base, totals.cur, rd, prevJab, totals.acc.tAmt); const jabPrev = prevJab || zeroJab(jabCur); const jabAcc = sumJab(jabPrev, jabCur); prevJab = jabAcc; const contractTotal = n0(base.kj.contractFinal); out.push({ round: k0 + k, rows, totals, jab: { prev: jabPrev, cur: jabCur, acc: jabAcc }, contractTotal, rate: { prev: pct(jabPrev.total, contractTotal), cur: pct(jabCur.total, contractTotal), acc: pct(jabAcc.total, contractTotal), }, }); }); return out; } // 집계수준 필터 — 머리 합계(전회·금회·누계·계약)를 얹고 고른 단 이상 머리만 function aggregate(base, round, minLevel = 0) { const byId = new Map(round.rows.map((r) => [r.id, r])); const nodes = base.nodes.map((nd) => { const o = Object.assign({}, nd, { st: {} }); if (nd.kind === "leaf") { const r = byId.get(nd.id); for (const k of ["ctr", "prev", "cur", "acc"]) o.st[k] = r ? r[k] : zero(); o.row = r; } return o; }); Change.sumHeads(nodes, ["ctr", "prev", "cur", "acc"]); return nodes.filter((nd) => minLevel <= 0 ? nd.kind === "head" || nd.kind === "leaf" : nd.kind === "head" && nd.level >= minLevel, ); } // 준공조서(0646·0647) — 서식 칸은 계약금액 | 준공금액 둘(SET_REP) · 준공금액 산식 원본 미확인 // (관찰: 내역서 머리 「◎ 준공 정산액」/「◎ 기성부분금액」 = PRJLST TCOST + 인쇄 열 칸 — ST_P_ConEst 0x4790d5) // 여기서는 마지막 회차 누계 이관(앱 가정) function completion(base, runs) { const last = runs[runs.length - 1]; return { contract: n0(base.kj.contractFinal), done: last ? last.jab.acc.total : 0, direct: { contract: base.direct, done: last ? last.totals.acc.tAmt : 0 }, assumed: true, }; } // 원본 저장 형식 — 기성 프로젝트(PCLASS 6 · K_ORD 회차) 한 회차를 BOQ 칸 · PRJLST · PRJLST.SMT 로 // BOQ: QTYn · M_/L_/S_/T_AMTn (n 1 계약 · 2 누계 · 3 전회 · 4 금회) // PRJLST: TCOSTn·MCOSTn·LCOSTn·SCOSTn 직접비 · COSTW4 = 금회 총공사비 · COSTW3 = 누계 총공사비(0x4ca7f1~0x4ca86b) // SMT: [2_KANJUB] 「2_C_코드」= 누계 · [4_KANJUB] 「4_C_코드」= 금회(0x4cac0c · 0x4caa48 · 섹션 이름 'KANJUB' 0x4c9b06) function toStorage(base, round) { const slot = { 1: "ctr", 2: "acc", 3: "prev", 4: "cur" }; const qkey = { 1: "ctrQty", 2: "accQty", 3: "prevQty", 4: "curQty" }; const boq = round.rows.map((r) => { const u = { _recno: r.recno, id: r.id }; for (let n = 1; n <= 4; n++) { const a = r[slot[n]]; Object.assign(u, { [`QTY${n}`]: r[qkey[n]], [`M_AMT${n}`]: n0(a.mAmt), [`L_AMT${n}`]: n0(a.lAmt), [`S_AMT${n}`]: n0(a.sAmt), [`T_AMT${n}`]: n0(a.tAmt), }); } return u; }); const prjlst = { PCLASS: 6, K_ORD: round.round }; for (let n = 1; n <= 4; n++) { const t = round.totals[slot[n]]; Object.assign(prjlst, { [`TCOST${n}`]: t.tAmt, [`MCOST${n}`]: t.mAmt, [`LCOST${n}`]: t.lAmt, [`SCOST${n}`]: t.sAmt, }); } prjlst.COSTW3 = round.jab.acc.total; prjlst.COSTW4 = round.jab.cur.total; const sec = (n, jab) => { const o = {}; jab.items.forEach((it) => C_CODE[it.code] && (o[`${n}_${C_CODE[it.code]}`] = it.amount)); o[`${n}_C_KONGGUP`] = jab.supply; o[`${n}_C_BUGA`] = jab.vat; o[`${n}_C_DOGUP`] = jab.total; return o; }; return { boq, prjlst, smt: { "2_KANJUB": sec(2, round.jab.acc), "4_KANJUB": sec(4, round.jab.cur) }, }; } // 다음 회차 등록 — 저장 형식(toStorage 꼴)을 STmate 회차 등록 규칙으로 바꿈 // [4_KANJUB] 는 원본에서 섹션째 지움(EraseSection 0x4567b9) — 앱 저장은 키 값 0 으로 씀 function nextRound(st) { const boq = st.boq.map((u) => { const o = Object.assign({}, u, { QTY3: u.QTY2, QTY4: 0 }); ["M_AMT", "L_AMT", "S_AMT", "T_AMT"].forEach((f) => { o[`${f}3`] = u[`${f}2`]; o[`${f}4`] = 0; }); return o; }); const prjlst = Object.assign({}, st.prjlst, { K_ORD: n0(st.prjlst.K_ORD) + 1, TCOST3: st.prjlst.TCOST2, TCOST4: 0, }); const s2 = st.smt["2_KANJUB"] || {}; const sec3 = Object.fromEntries(Object.entries(s2).map(([k, v]) => [`3${k.slice(1)}`, v])); const sec4 = Object.fromEntries(Object.keys(st.smt["4_KANJUB"] || {}).map((k) => [k, 0])); return { boq, prjlst, smt: { "2_KANJUB": s2, "3_KANJUB": sec3, "4_KANJUB": sec4 } }; } // 기성 프로젝트 불러오기 — BOQ QTY3(전회)·QTY4(금회)·M_/L_/S_/T_AMT3 · [3_KANJUB] 또는 [2_KANJUB]−[4_KANJUB] 로 seed function fromStorage(base, boqRows, prjlst, smt) { const byRec = new Map((boqRows || []).map((r) => [r._recno, r])); const qtyById = {}, amtById = {}, curById = {}; base.leaves.forEach((lf) => { const r = byRec.get(lf.recno); if (!r) return; qtyById[lf.id] = n0(r.QTY3); if (n0(r.QTY4)) curById[lf.id] = n0(r.QTY4); // 금회 0 = 빈칸(일괄 기성율 적용) amtById[lf.id] = { mAmt: n0(r.M_AMT3), lAmt: n0(r.L_AMT3), sAmt: n0(r.S_AMT3), tAmt: n0(r.T_AMT3), }; }); const kv = smt || {}; const jab = {}; const val = (n, c) => kv[`${n}_${c}`]; Object.entries(C_CODE).forEach(([code, c]) => { const p3 = val(3, c); jab[code] = p3 != null ? n0(p3) : n0(val(2, c)) - n0(val(4, c)); }); jab.SUPPLY = val(3, "C_KONGGUP") != null ? n0(val(3, "C_KONGGUP")) : 0; jab.BUGA = val(3, "C_BUGA") != null ? n0(val(3, "C_BUGA")) : 0; const kOrd = Math.max(1, n0(prjlst && prjlst.K_ORD)); return { seed: { kOrd, qtyById, amtById, jab }, round: { pct: 0, qtyById: curById, vatMode: 1 }, }; } return { VAT_MODES, LEVELS, DEFAULT_LEVEL, C_CODE, contractBase, run, aggregate, completion, jabFor, capCur, toStorage, fromStorage, nextRound, }; })(); if (typeof window !== "undefined") window.STMATE_PROGRESS = StmateProgress; if (typeof module !== "undefined" && module.exports) module.exports = StmateProgress;