232 lines
8.8 KiB
JavaScript
232 lines
8.8 KiB
JavaScript
/* -*- coding: utf-8 -*- */
|
|
/**
|
|
* 기성 계산 (progress_calc.js) — 회차별 전회·금회·누계 금액과 비율 · 공종별 기성수량·잔량 · 기성 제잡비 · 부가세 4택
|
|
* 근거: 지식DB STmate/old/20_분석 27 §3(wM_KanJub_K · 금회직접공사비 × 계약제잡비율 · 4단 누적 · 부가세 4택
|
|
* · 공급가액/총공사비 절사 · 이윤 직접입력 · 서식 0632~0638) · 35 §3(기성 단계) · 11 §10(계약 제잡비율 경로)
|
|
* 미확인(표본 0건, 27 §9): 누계 금액 산식 · 절사 순서 — 여기서는 누계 = trunc(계약단가 × 누계수량), 금회 = 누계 − 전회
|
|
* 시험: resources/tester/test_stmate_stage_progress.py (자기 일관성 — 전회+금회=누계 · 100% 누계=계약)
|
|
*/
|
|
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택(27 §3.3) — 0 직접입력 · 1 공급가액 10% · 2 재료비 10% · 3 재료비+산출경비 10%
|
|
const VAT_MODES = ["직접입력", "공급가액의 10%", "재료비의 10%", "재료비+산출경비의 10%"];
|
|
// 집계수준(rd_Filter) — 소계 이상 … 총합계 (머리 단 T2~T9)
|
|
const LEVELS = [
|
|
[0, "공종 전부"],
|
|
[2, "소계 이상"],
|
|
[3, "합계 이상"],
|
|
[4, "총계 이상"],
|
|
[5, "총합계"],
|
|
];
|
|
|
|
// 계약 기준 — 계약 내역(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,
|
|
code: nd.newCode || nd.code,
|
|
name: nd.name,
|
|
spec: nd.spec,
|
|
unit: nd.unit,
|
|
gov: nd.gov,
|
|
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);
|
|
// 계약잡비율 = 계약 제잡비 항목 금액 ÷ 계약 직접공사비
|
|
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) },
|
|
]);
|
|
const rates = Object.fromEntries(lines.map((l) => [l.code, direct ? l.amount / direct : 0]));
|
|
return { conv, kj, leaves, lines, rates, 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 sub = (a, b) => Object.fromEntries(KEYS.map((k) => [k, n0(a[k]) - n0(b[k])]));
|
|
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]));
|
|
const pct = (a, b) => (b ? Math.round((a / b) * 1e5) / 1e3 : 0);
|
|
|
|
// 회차 하나의 제잡비·공급가액·부가세 — 금회직접 × 계약잡비율(항목별 버림)
|
|
function jabFor(base, cur, opt) {
|
|
const dT = cur.mAmt + cur.lAmt + cur.sAmt;
|
|
const items = base.lines.map((l) => ({
|
|
code: l.code,
|
|
label: l.label,
|
|
rate: base.rates[l.code],
|
|
amount:
|
|
l.code === "IYUN" && opt.iyunManual != null
|
|
? n0(opt.iyunManual)
|
|
: roundDown(dT * base.rates[l.code], 0),
|
|
}));
|
|
let supply = dT + items.reduce((a, it) => a + it.amount, 0);
|
|
const sj = n0(opt.supplyJeol);
|
|
if (sj < 0) {
|
|
const cut = supply - roundDown(supply, sj);
|
|
const iy = items.find((it) => it.code === "IYUN");
|
|
if (iy) iy.amount -= cut;
|
|
supply -= cut;
|
|
}
|
|
const mode = opt.vatMode == null ? 1 : Number(opt.vatMode);
|
|
const vat =
|
|
mode === 0
|
|
? n0(opt.vatManual)
|
|
: mode === 2
|
|
? roundDown(cur.mAmt * 0.1, 0)
|
|
: mode === 3
|
|
? roundDown((cur.mAmt + cur.sAmt) * 0.1, 0)
|
|
: roundDown(supply * 0.1, 0);
|
|
let total = supply + vat;
|
|
const tj = n0(opt.totalJeol);
|
|
let totalCut = 0;
|
|
if (tj < 0) {
|
|
totalCut = total - roundDown(total, tj);
|
|
const iy = items.find((it) => it.code === "IYUN");
|
|
if (iy) iy.amount -= totalCut;
|
|
supply -= totalCut;
|
|
total -= totalCut;
|
|
}
|
|
return { direct: dT, items, supply, vat, total, totalCut };
|
|
}
|
|
|
|
// 회차 실행 — rounds[k] = { qtyById: {id: 금회수량}, pct: 일괄 기성율(%), vatMode, vatManual, ... }
|
|
function run(base, rounds) {
|
|
const accQty = Object.fromEntries(base.leaves.map((lf) => [lf.id, 0]));
|
|
const accAmt = Object.fromEntries(base.leaves.map((lf) => [lf.id, zero()]));
|
|
let prevJab = null;
|
|
const out = [];
|
|
rounds.forEach((rd, k) => {
|
|
const rows = base.leaves.map((lf) => {
|
|
const q =
|
|
rd.qtyById && rd.qtyById[lf.id] != null
|
|
? n0(rd.qtyById[lf.id])
|
|
: r4((lf.qty * n0(rd.pct)) / 100);
|
|
const prevQ = accQty[lf.id],
|
|
nowQ = r4(prevQ + q);
|
|
const prev = accAmt[lf.id],
|
|
acc = amountsAt(lf, nowQ),
|
|
cur = sub(acc, prev);
|
|
accQty[lf.id] = nowQ;
|
|
accAmt[lf.id] = acc;
|
|
return {
|
|
id: lf.id,
|
|
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);
|
|
const jabPrev = prevJab || {
|
|
direct: 0,
|
|
items: jabCur.items.map((it) => Object.assign({}, it, { amount: 0 })),
|
|
supply: 0,
|
|
vat: 0,
|
|
total: 0,
|
|
};
|
|
const jabAcc = {
|
|
direct: jabPrev.direct + jabCur.direct,
|
|
items: jabCur.items.map((it, i) =>
|
|
Object.assign({}, it, { amount: jabPrev.items[i].amount + it.amount }),
|
|
),
|
|
supply: jabPrev.supply + jabCur.supply,
|
|
vat: jabPrev.vat + jabCur.vat,
|
|
total: jabPrev.total + jabCur.total,
|
|
};
|
|
prevJab = jabAcc;
|
|
const contractTotal = n0(base.kj.contractFinal);
|
|
out.push({
|
|
round: k + 1,
|
|
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,
|
|
);
|
|
}
|
|
|
|
// 준공조서 — 계약금액 | 준공금액(최종 누계, 35 §3 미확인 규칙 = 마지막 회차 누계 이관)
|
|
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 },
|
|
};
|
|
}
|
|
|
|
return { VAT_MODES, LEVELS, contractBase, run, aggregate, completion, jabFor };
|
|
})();
|
|
|
|
if (typeof window !== "undefined") window.STMATE_PROGRESS = StmateProgress;
|
|
if (typeof module !== "undefined" && module.exports) module.exports = StmateProgress;
|